Skip to content

Commit b6c2998

Browse files
authored
feat: add aibridge-proxy module for AI Bridge Proxy workspace setup (#721)
## Description Add `aibridge-proxy` module that configures workspaces to use AI Bridge Proxy. Downloads the proxy's CA certificate and exposes `proxy_auth_url` and `cert_path` outputs for tool-specific modules to configure the proxy scoped to their process. The module does not set proxy environment variables globally in the workspace. ## Type of Change - [x] New module - [ ] New template - [ ] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information <!-- Delete this section if not applicable --> **Path:** `registry/coder/modules/aibridge-proxy` **New version:** `v1.0.0` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues Closes: coder/internal#1187
1 parent ac49e6e commit b6c2998

5 files changed

Lines changed: 713 additions & 0 deletions

File tree

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
display_name: AI Bridge Proxy
3+
description: Configure a workspace to route AI tool traffic through AI Bridge via AI Bridge Proxy.
4+
icon: ../../../../.icons/coder.svg
5+
verified: true
6+
tags: [helper, aibridge]
7+
---
8+
9+
# AI Bridge Proxy
10+
11+
This module configures a Coder workspace to use [AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy).
12+
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.
13+
14+
```tf
15+
module "aibridge-proxy" {
16+
source = "registry.coder.com/coder/aibridge-proxy/coder"
17+
version = "1.0.0"
18+
agent_id = coder_agent.main.id
19+
proxy_url = "https://aiproxy.example.com"
20+
}
21+
```
22+
23+
> [!NOTE]
24+
> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance).
25+
> 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.
26+
27+
## How it works
28+
29+
[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.
30+
Any process with the proxy environment variables set will route **all** its traffic through the proxy.
31+
32+
This module **does not** set proxy environment variables globally on the workspace.
33+
Instead, it provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules consume to configure proxy routing.
34+
See the [Copilot module](https://registry.coder.com/modules/coder-labs/copilot) for a working integration example.
35+
36+
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.
37+
38+
> [!WARNING]
39+
> 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.
40+
> Tools that depend on the proxy will not work until the issue is resolved. Check the workspace build logs for details.
41+
42+
## Startup Coordination
43+
44+
When used with tool-specific modules (e.g. [Copilot](https://registry.coder.com/modules/coder-labs/copilot)),
45+
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.
46+
47+
Dependent modules are unblocked once the setup script finishes, regardless of success or failure.
48+
If the setup fails, dependent modules are expected to detect the failure and handle the error accordingly.
49+
50+
To enable startup coordination, set `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment:
51+
52+
```hcl
53+
env = [
54+
"CODER_AGENT_TOKEN=${coder_agent.main.token}",
55+
"CODER_AGENT_SOCKET_SERVER_ENABLED=true",
56+
]
57+
```
58+
59+
> [!NOTE]
60+
> [Startup coordination](https://coder.com/docs/admin/templates/startup-coordination) requires Coder >= v2.30.
61+
> 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.
62+
63+
## Examples
64+
65+
### Custom certificate path
66+
67+
```tf
68+
module "aibridge-proxy" {
69+
source = "registry.coder.com/coder/aibridge-proxy/coder"
70+
version = "1.0.0"
71+
agent_id = coder_agent.main.id
72+
proxy_url = "https://aiproxy.example.com"
73+
cert_path = "/home/coder/.certs/aibridge-proxy-ca.pem"
74+
}
75+
```
76+
77+
### Proxy with custom port
78+
79+
For deployments where the proxy is accessed directly on a configured port.
80+
See [security considerations](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup#security-considerations) for network access guidelines.
81+
82+
```tf
83+
module "aibridge-proxy" {
84+
source = "registry.coder.com/coder/aibridge-proxy/coder"
85+
version = "1.0.0"
86+
agent_id = coder_agent.main.id
87+
proxy_url = "http://internal-proxy:8888"
88+
}
89+
```
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
import { serve } from "bun";
2+
import {
3+
afterEach,
4+
beforeAll,
5+
describe,
6+
expect,
7+
it,
8+
setDefaultTimeout,
9+
} from "bun:test";
10+
import {
11+
execContainer,
12+
findResourceInstance,
13+
removeContainer,
14+
runContainer,
15+
runTerraformApply,
16+
runTerraformInit,
17+
testRequiredVariables,
18+
} from "~test";
19+
20+
let cleanupFunctions: (() => Promise<void>)[] = [];
21+
const registerCleanup = (cleanup: () => Promise<void>) => {
22+
cleanupFunctions.push(cleanup);
23+
};
24+
afterEach(async () => {
25+
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
26+
cleanupFunctions = [];
27+
for (const cleanup of cleanupFnsCopy) {
28+
try {
29+
await cleanup();
30+
} catch (error) {
31+
console.error("Error during cleanup:", error);
32+
}
33+
}
34+
});
35+
36+
const FAKE_CERT =
37+
"-----BEGIN CERTIFICATE-----\nMIIBfakecert\n-----END CERTIFICATE-----\n";
38+
39+
// Runs terraform apply to render the setup script, then starts a Docker
40+
// container where we can execute it against a mock server.
41+
const setupContainer = async (vars: Record<string, string> = {}) => {
42+
const state = await runTerraformApply(import.meta.dir, {
43+
agent_id: "foo",
44+
proxy_url: "https://aiproxy.example.com",
45+
...vars,
46+
});
47+
const instance = findResourceInstance(state, "coder_script");
48+
const id = await runContainer("lorello/alpine-bash");
49+
50+
registerCleanup(async () => {
51+
await removeContainer(id);
52+
});
53+
54+
return { id, instance };
55+
};
56+
57+
// Starts a mock HTTP server that simulates the Coder API certificate endpoint.
58+
// Returns the server and its base URL.
59+
const setupServer = (handler: (req: Request) => Response) => {
60+
const server = serve({
61+
fetch: handler,
62+
port: 0,
63+
});
64+
registerCleanup(async () => {
65+
server.stop();
66+
});
67+
return {
68+
server,
69+
// Base URL without trailing slash
70+
url: server.url.toString().slice(0, -1),
71+
};
72+
};
73+
74+
setDefaultTimeout(30 * 1000);
75+
76+
describe("aibridge-proxy", () => {
77+
beforeAll(async () => {
78+
await runTerraformInit(import.meta.dir);
79+
});
80+
81+
// Verify that agent_id and proxy_url are required.
82+
testRequiredVariables(import.meta.dir, {
83+
agent_id: "foo",
84+
proxy_url: "https://aiproxy.example.com",
85+
});
86+
87+
it("downloads the CA certificate successfully", async () => {
88+
let receivedToken = "";
89+
const { url } = setupServer((req) => {
90+
const reqUrl = new URL(req.url);
91+
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
92+
receivedToken = req.headers.get("Coder-Session-Token") || "";
93+
return new Response(FAKE_CERT, {
94+
status: 200,
95+
headers: { "Content-Type": "application/x-pem-file" },
96+
});
97+
}
98+
return new Response("not found", { status: 404 });
99+
});
100+
101+
const { id, instance } = await setupContainer();
102+
103+
// Override ACCESS_URL and SESSION_TOKEN at runtime to point at the mock server.
104+
const exec = await execContainer(id, [
105+
"env",
106+
`ACCESS_URL=${url}`,
107+
"SESSION_TOKEN=test-session-token-123",
108+
"bash",
109+
"-c",
110+
instance.script,
111+
]);
112+
expect(exec.exitCode).toBe(0);
113+
expect(exec.stdout).toContain(
114+
"AI Bridge Proxy CA certificate saved to /tmp/aibridge-proxy/ca-cert.pem",
115+
);
116+
117+
// Verify the cert was written to the default path.
118+
const certContent = await execContainer(id, [
119+
"cat",
120+
"/tmp/aibridge-proxy/ca-cert.pem",
121+
]);
122+
expect(certContent.stdout).toContain("BEGIN CERTIFICATE");
123+
124+
// Verify the session token was sent in the request header.
125+
expect(receivedToken).toBe("test-session-token-123");
126+
});
127+
128+
it("fails when the server is unreachable", async () => {
129+
const { id, instance } = await setupContainer();
130+
131+
// Port 9999 has nothing listening, so curl will fail to connect.
132+
const exec = await execContainer(id, [
133+
"env",
134+
"ACCESS_URL=http://localhost:9999",
135+
"SESSION_TOKEN=mock-token",
136+
"bash",
137+
"-c",
138+
instance.script,
139+
]);
140+
expect(exec.exitCode).not.toBe(0);
141+
expect(exec.stdout).toContain(
142+
"AI Bridge Proxy setup failed: could not connect to",
143+
);
144+
});
145+
146+
it("fails when the server returns a non-200 status", async () => {
147+
const { url } = setupServer(() => {
148+
return new Response("not found", { status: 404 });
149+
});
150+
151+
const { id, instance } = await setupContainer();
152+
153+
const exec = await execContainer(id, [
154+
"env",
155+
`ACCESS_URL=${url}`,
156+
"SESSION_TOKEN=mock-token",
157+
"bash",
158+
"-c",
159+
instance.script,
160+
]);
161+
expect(exec.exitCode).not.toBe(0);
162+
expect(exec.stdout).toContain(
163+
"AI Bridge Proxy setup failed: unexpected response",
164+
);
165+
});
166+
167+
it("fails when the server returns an empty response", async () => {
168+
const { url } = setupServer((req) => {
169+
const reqUrl = new URL(req.url);
170+
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
171+
return new Response("", { status: 200 });
172+
}
173+
return new Response("not found", { status: 404 });
174+
});
175+
176+
const { id, instance } = await setupContainer();
177+
178+
const exec = await execContainer(id, [
179+
"env",
180+
`ACCESS_URL=${url}`,
181+
"SESSION_TOKEN=mock-token",
182+
"bash",
183+
"-c",
184+
instance.script,
185+
]);
186+
expect(exec.exitCode).not.toBe(0);
187+
expect(exec.stdout).toContain(
188+
"AI Bridge Proxy setup failed: downloaded certificate is empty.",
189+
);
190+
});
191+
192+
it("saves the certificate to a custom path", async () => {
193+
const { url } = setupServer((req) => {
194+
const reqUrl = new URL(req.url);
195+
if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") {
196+
return new Response(FAKE_CERT, {
197+
status: 200,
198+
headers: { "Content-Type": "application/x-pem-file" },
199+
});
200+
}
201+
return new Response("not found", { status: 404 });
202+
});
203+
204+
// Pass a custom cert_path to terraform apply so the script uses it.
205+
const { id, instance } = await setupContainer({
206+
cert_path: "/tmp/custom/certs/proxy-ca.pem",
207+
});
208+
209+
const exec = await execContainer(id, [
210+
"env",
211+
`ACCESS_URL=${url}`,
212+
"SESSION_TOKEN=mock-token",
213+
"bash",
214+
"-c",
215+
instance.script,
216+
]);
217+
expect(exec.exitCode).toBe(0);
218+
expect(exec.stdout).toContain(
219+
"AI Bridge Proxy CA certificate saved to /tmp/custom/certs/proxy-ca.pem",
220+
);
221+
222+
const certContent = await execContainer(id, [
223+
"cat",
224+
"/tmp/custom/certs/proxy-ca.pem",
225+
]);
226+
expect(certContent.stdout).toContain("BEGIN CERTIFICATE");
227+
});
228+
229+
it("does not create global proxy env vars via coder_env", async () => {
230+
const state = await runTerraformApply(import.meta.dir, {
231+
agent_id: "foo",
232+
proxy_url: "https://aiproxy.example.com",
233+
});
234+
235+
// Proxy env vars should NOT be set globally via coder_env.
236+
// They are intended to be scoped to specific tool processes.
237+
const proxyEnvVarNames = [
238+
"HTTP_PROXY",
239+
"HTTPS_PROXY",
240+
"NODE_EXTRA_CA_CERTS",
241+
"SSL_CERT_FILE",
242+
"REQUESTS_CA_BUNDLE",
243+
"CURL_CA_BUNDLE",
244+
];
245+
const proxyEnvVars = state.resources.filter(
246+
(r) =>
247+
r.type === "coder_env" &&
248+
r.instances.some((i) =>
249+
proxyEnvVarNames.includes(i.attributes.name as string),
250+
),
251+
);
252+
expect(proxyEnvVars.length).toBe(0);
253+
});
254+
});

0 commit comments

Comments
 (0)