mirror of
https://github.com/coder/registry.git
synced 2026-06-03 04:58:15 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e75d5d762 | |||
| b6c2998eb3 | |||
| ac49e6eef5 |
@@ -3,7 +3,7 @@ display_name: Copilot CLI
|
||||
description: GitHub Copilot CLI agent for AI-powered terminal assistance
|
||||
icon: ../../../../.icons/github.svg
|
||||
verified: false
|
||||
tags: [agent, copilot, ai, github, tasks]
|
||||
tags: [agent, copilot, ai, github, tasks, aibridge]
|
||||
---
|
||||
|
||||
# Copilot
|
||||
@@ -13,7 +13,7 @@ Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-c
|
||||
```tf
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
}
|
||||
@@ -51,7 +51,7 @@ data "coder_parameter" "ai_prompt" {
|
||||
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
|
||||
@@ -71,7 +71,7 @@ Customize tool permissions, MCP servers, and Copilot settings:
|
||||
```tf
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
|
||||
@@ -142,7 +142,7 @@ variable "github_token" {
|
||||
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder/projects"
|
||||
github_token = var.github_token
|
||||
@@ -156,7 +156,7 @@ Run Copilot as a command-line tool without task reporting or web interface. This
|
||||
```tf
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
agent_id = coder_agent.example.id
|
||||
workdir = "/home/coder"
|
||||
report_tasks = false
|
||||
@@ -164,6 +164,39 @@ module "copilot" {
|
||||
}
|
||||
```
|
||||
|
||||
### Usage with AI Bridge Proxy
|
||||
|
||||
[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) routes Copilot traffic through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) for centralized LLM management and governance.
|
||||
The proxy environment variables are scoped to the Copilot process only and do not affect other workspace traffic.
|
||||
|
||||
```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"
|
||||
}
|
||||
|
||||
module "copilot" {
|
||||
source = "registry.coder.com/coder-labs/copilot/coder"
|
||||
version = "0.4.0"
|
||||
agent_id = coder_agent.main.id
|
||||
workdir = "/home/coder/projects"
|
||||
enable_aibridge_proxy = true
|
||||
aibridge_proxy_auth_url = module.aibridge-proxy.proxy_auth_url
|
||||
aibridge_proxy_cert_path = module.aibridge-proxy.cert_path
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance).
|
||||
> 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.
|
||||
> GitHub authentication is still required for Copilot as the proxy authenticates with AI Bridge using the Coder session token, but does not replace GitHub authentication.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> When using AI Bridge Proxy, enable [startup coordination](https://coder.com/docs/admin/templates/startup-coordination) by setting `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment.
|
||||
> This ensures the Copilot module waits for the `aibridge-proxy` module to complete before starting. Without it, the Copilot start script may fail if the AI Bridge Proxy setup has not completed in time.
|
||||
|
||||
## Authentication
|
||||
|
||||
The module supports multiple authentication methods (in priority order):
|
||||
|
||||
@@ -234,3 +234,116 @@ run "app_slug_is_consistent" {
|
||||
error_message = "module_dir_name should be '.copilot-module'"
|
||||
}
|
||||
}
|
||||
|
||||
run "aibridge_proxy_defaults" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_aibridge_proxy == false
|
||||
error_message = "enable_aibridge_proxy should default to false"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.aibridge_proxy_auth_url == null
|
||||
error_message = "aibridge_proxy_auth_url should default to null"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.aibridge_proxy_cert_path == null
|
||||
error_message = "aibridge_proxy_cert_path should default to null"
|
||||
}
|
||||
}
|
||||
|
||||
run "aibridge_proxy_enabled" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-aibridge-proxy"
|
||||
workdir = "/home/coder"
|
||||
enable_aibridge_proxy = true
|
||||
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
|
||||
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_aibridge_proxy == true
|
||||
error_message = "AI Bridge Proxy should be enabled"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.aibridge_proxy_auth_url == "https://coder:mock-token@aiproxy.example.com"
|
||||
error_message = "AI Bridge Proxy auth URL should match the input variable"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.aibridge_proxy_cert_path == "/tmp/aibridge-proxy/ca-cert.pem"
|
||||
error_message = "AI Bridge Proxy cert path should match the input variable"
|
||||
}
|
||||
}
|
||||
|
||||
run "aibridge_proxy_validation_missing_proxy_auth_url" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-validation"
|
||||
workdir = "/home/coder"
|
||||
enable_aibridge_proxy = true
|
||||
aibridge_proxy_auth_url = ""
|
||||
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.enable_aibridge_proxy,
|
||||
]
|
||||
}
|
||||
|
||||
run "aibridge_proxy_validation_missing_cert_path" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent-validation"
|
||||
workdir = "/home/coder"
|
||||
enable_aibridge_proxy = true
|
||||
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
|
||||
aibridge_proxy_cert_path = ""
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.enable_aibridge_proxy,
|
||||
]
|
||||
}
|
||||
|
||||
run "aibridge_proxy_with_copilot_config" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workdir = "/home/coder"
|
||||
copilot_model = "gpt-5"
|
||||
github_token = "ghp_test123"
|
||||
allow_all_tools = true
|
||||
enable_aibridge_proxy = true
|
||||
aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com"
|
||||
aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = var.enable_aibridge_proxy == true
|
||||
error_message = "AI Bridge Proxy should be enabled"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_env.github_token) == 1
|
||||
error_message = "github_token environment variable should be set alongside proxy"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_env.copilot_model) == 1
|
||||
error_message = "copilot_model environment variable should be set alongside proxy"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
terraform {
|
||||
required_version = ">= 1.0"
|
||||
required_version = ">= 1.9"
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
@@ -173,6 +173,35 @@ variable "post_install_script" {
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "enable_aibridge_proxy" {
|
||||
type = bool
|
||||
description = "Route Copilot traffic through AI Bridge Proxy. See https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy"
|
||||
default = false
|
||||
|
||||
validation {
|
||||
condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_auth_url != null && length(var.aibridge_proxy_auth_url) > 0)
|
||||
error_message = "aibridge_proxy_auth_url is required when enable_aibridge_proxy is true."
|
||||
}
|
||||
|
||||
validation {
|
||||
condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_cert_path != null && length(var.aibridge_proxy_cert_path) > 0)
|
||||
error_message = "aibridge_proxy_cert_path is required when enable_aibridge_proxy is true."
|
||||
}
|
||||
}
|
||||
|
||||
variable "aibridge_proxy_auth_url" {
|
||||
type = string
|
||||
description = "AI Bridge Proxy URL with authentication. Use the proxy_auth_url output from the aibridge-proxy module."
|
||||
default = null
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "aibridge_proxy_cert_path" {
|
||||
type = string
|
||||
description = "Path to the AI Bridge Proxy CA certificate. Use the cert_path output from the aibridge-proxy module."
|
||||
default = null
|
||||
}
|
||||
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
@@ -279,6 +308,9 @@ module "agentapi" {
|
||||
ARG_TRUSTED_DIRECTORIES='${join(",", var.trusted_directories)}' \
|
||||
ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \
|
||||
ARG_RESUME_SESSION='${var.resume_session}' \
|
||||
ARG_ENABLE_AIBRIDGE_PROXY='${var.enable_aibridge_proxy}' \
|
||||
ARG_AIBRIDGE_PROXY_AUTH_URL='${var.aibridge_proxy_auth_url != null ? var.aibridge_proxy_auth_url : ""}' \
|
||||
ARG_AIBRIDGE_PROXY_CERT_PATH='${var.aibridge_proxy_cert_path != null ? var.aibridge_proxy_cert_path : ""}' \
|
||||
/tmp/start.sh
|
||||
EOT
|
||||
|
||||
|
||||
@@ -22,6 +22,9 @@ ARG_DENY_TOOLS=${ARG_DENY_TOOLS:-}
|
||||
ARG_TRUSTED_DIRECTORIES=${ARG_TRUSTED_DIRECTORIES:-}
|
||||
ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github}
|
||||
ARG_RESUME_SESSION=${ARG_RESUME_SESSION:-true}
|
||||
ARG_ENABLE_AIBRIDGE_PROXY=${ARG_ENABLE_AIBRIDGE_PROXY:-false}
|
||||
ARG_AIBRIDGE_PROXY_AUTH_URL=${ARG_AIBRIDGE_PROXY_AUTH_URL:-}
|
||||
ARG_AIBRIDGE_PROXY_CERT_PATH=${ARG_AIBRIDGE_PROXY_CERT_PATH:-}
|
||||
|
||||
validate_copilot_installation() {
|
||||
if ! command_exists copilot; then
|
||||
@@ -118,6 +121,48 @@ setup_github_authentication() {
|
||||
return 0
|
||||
}
|
||||
|
||||
setup_aibridge_proxy() {
|
||||
if [ "$ARG_ENABLE_AIBRIDGE_PROXY" != "true" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Setting up AI Bridge Proxy..."
|
||||
|
||||
# Wait for the aibridge-proxy module to finish.
|
||||
# Uses startup coordination to block until aibridge-proxy-setup signals completion.
|
||||
if command -v coder > /dev/null 2>&1; then
|
||||
coder exp sync want "copilot-aibridge" "aibridge-proxy-setup" > /dev/null 2>&1 || true
|
||||
coder exp sync start "copilot-aibridge" > /dev/null 2>&1 || true
|
||||
trap 'coder exp sync complete "copilot-aibridge" > /dev/null 2>&1 || true' EXIT
|
||||
fi
|
||||
|
||||
if [ -z "$ARG_AIBRIDGE_PROXY_AUTH_URL" ]; then
|
||||
echo "ERROR: AI Bridge Proxy is enabled but no proxy auth URL provided."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then
|
||||
echo "ERROR: AI Bridge Proxy is enabled but no certificate path provided."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then
|
||||
echo "ERROR: AI Bridge Proxy certificate not found at $ARG_AIBRIDGE_PROXY_CERT_PATH."
|
||||
echo " Ensure the aibridge-proxy module has successfully completed setup."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Set proxy environment variables scoped to this process tree only.
|
||||
# These are inherited by the agentapi/copilot process below,
|
||||
# but do not affect other workspace processes, avoiding routing
|
||||
# unnecessary traffic through the proxy.
|
||||
export HTTPS_PROXY="$ARG_AIBRIDGE_PROXY_AUTH_URL"
|
||||
export NODE_EXTRA_CA_CERTS="$ARG_AIBRIDGE_PROXY_CERT_PATH"
|
||||
|
||||
echo "✓ AI Bridge Proxy configured"
|
||||
echo " CA certificate: $ARG_AIBRIDGE_PROXY_CERT_PATH"
|
||||
}
|
||||
|
||||
start_agentapi() {
|
||||
echo "Starting in directory: $ARG_WORKDIR"
|
||||
cd "$ARG_WORKDIR"
|
||||
@@ -157,5 +202,6 @@ start_agentapi() {
|
||||
}
|
||||
|
||||
setup_github_authentication
|
||||
setup_aibridge_proxy
|
||||
validate_copilot_installation
|
||||
start_agentapi
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
---
|
||||
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 provides Terraform outputs (`proxy_auth_url` and `cert_path`) 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 Governance Add-On](https://coder.com/docs/ai-coder/ai-governance).
|
||||
> 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](https://coder.com/docs/ai-coder/ai-bridge/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 Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules consume to configure proxy routing.
|
||||
See the [Copilot module](https://registry.coder.com/modules/coder-labs/copilot) for a working integration example.
|
||||
|
||||
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.
|
||||
|
||||
Dependent modules are unblocked once the setup script finishes, regardless of success or failure.
|
||||
If the setup fails, dependent modules are expected to detect the failure and handle the error accordingly.
|
||||
|
||||
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](https://coder.com/docs/admin/templates/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"
|
||||
}
|
||||
```
|
||||
@@ -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<void>)[] = [];
|
||||
const registerCleanup = (cleanup: () => Promise<void>) => {
|
||||
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<string, string> = {}) => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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:<token>@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:<token>@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,
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
#!/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.
|
||||
# The trap ensures 'complete' is always called (even on failure) so dependent
|
||||
# scripts unblock promptly and can check for the certificate themselves.
|
||||
if command -v coder > /dev/null 2>&1; then
|
||||
coder exp sync start "aibridge-proxy-setup" > /dev/null 2>&1 || true
|
||||
trap 'coder exp sync complete "aibridge-proxy-setup" > /dev/null 2>&1 || true' EXIT
|
||||
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."
|
||||
@@ -67,7 +67,7 @@ variable "cli_app_display_name" {
|
||||
|
||||
variable "pre_install_script" {
|
||||
type = string
|
||||
description = "Custom script to run before installing Claude Code."
|
||||
description = "Custom script to run before installing Claude Code. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Claude Code initialization)."
|
||||
default = null
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user