diff --git a/.icons/poolside.svg b/.icons/poolside.svg new file mode 100644 index 000000000..c83f3bbff --- /dev/null +++ b/.icons/poolside.svg @@ -0,0 +1,4 @@ + + + + diff --git a/registry/coder-labs/.images/pool.png b/registry/coder-labs/.images/pool.png new file mode 100644 index 000000000..c110aef42 Binary files /dev/null and b/registry/coder-labs/.images/pool.png differ diff --git a/registry/coder-labs/modules/pool/README.md b/registry/coder-labs/modules/pool/README.md new file mode 100644 index 000000000..4cad810dd --- /dev/null +++ b/registry/coder-labs/modules/pool/README.md @@ -0,0 +1,259 @@ +--- +display_name: Pool CLI +icon: ../../../../.icons/poolside.svg +description: Install and configure Poolside's Pool coding agent in your workspace. +verified: false +tags: [agent, poolside, pool, ai, ai-gateway] +--- + +# Pool CLI + +Install and configure [Pool](https://docs.poolside.ai/cli/pool), Poolside's coding agent, in your Coder workspace. The module installs the `pool` CLI, accepts its EULA noninteractively, and wires Pool's authentication and endpoints to Coder so developers get a ready-to-use agent without pasting credentials by hand. + +```tf +module "pool" { + source = "registry.coder.com/coder-labs/pool/coder" + version = "0.1.0" + agent_id = coder_agent.main.id + + poolside_api_key = var.poolside_api_key +} +``` + +![Pool CLI running in a Coder workspace](../../.images/pool.png) + +The module accepts Pool's EULA noninteractively during installation, installs the CLI in `~/.local/bin` by default, and makes `pool` available to Coder scripts and interactive shells. + +> [!NOTE] +> Pass secrets such as `poolside_api_key` through a `sensitive` Terraform variable (for example `var.poolside_api_key`) rather than inline literals, so keys never land in template source or state diffs. The `poolside_api_key` input is already marked `sensitive = true`. + +## Dashboard entry point (`coder_app`) + +Add a `coder_app` to give developers a one-click launcher for Pool from the Coder dashboard. The module installs and configures the CLI; the app opens an interactive session. + +```tf +locals { + pool_workdir = "/home/coder/project" +} + +module "pool" { + source = "registry.coder.com/coder-labs/pool/coder" + version = "0.1.0" + agent_id = coder_agent.main.id + + poolside_api_key = var.poolside_api_key +} + +resource "coder_app" "pool" { + agent_id = coder_agent.main.id + slug = "pool" + display_name = "Pool" + icon = "/icon/poolside.svg" + open_in = "slim-window" + command = <<-EOT + #!/bin/bash + set -e + cd "${local.pool_workdir}" + pool + EOT +} +``` + +> [!NOTE] +> The `coder_app` command re-executes on every reconnect. This suits interactive `pool` (which stays alive). To keep a single long-lived session across reconnects and relaunches, use the persistent-session pattern in [Session continuity](#session-continuity). + +## Session continuity + +Run Pool once inside a persistent `tmux` session so the conversation survives dashboard reconnects and app relaunches. A `coder_script` starts the session on workspace start, and the `coder_app` attaches to that same session instead of launching a new `pool` process each time. + +```tf +locals { + pool_workdir = "/home/coder/project" +} + +module "pool" { + source = "registry.coder.com/coder-labs/pool/coder" + version = "0.1.0" + agent_id = coder_agent.main.id + + poolside_api_key = var.poolside_api_key +} + +resource "coder_script" "pool_session" { + agent_id = coder_agent.main.id + display_name = "Start Pool session" + run_on_start = true + script = <<-EOT + #!/bin/bash + set -euo pipefail + trap 'coder exp sync complete pool-session' EXIT + coder exp sync want pool-session ${join(" ", module.pool.scripts)} + coder exp sync start pool-session + + cd "${local.pool_workdir}" + tmux new-session -d -s pool 'pool' + EOT +} + +resource "coder_app" "pool" { + agent_id = coder_agent.main.id + slug = "pool" + display_name = "Pool" + icon = "/icon/poolside.svg" + command = <<-EOT + #!/bin/bash + set -e + exec tmux new-session -A -s pool 'pool' + EOT +} +``` + +`tmux new-session -A -s pool` attaches to the `pool` session created by the `coder_script` if it exists, or starts it otherwise, so developers always land back in the same running agent session. + +## AI governance + +Coder can govern how Pool authenticates and where its traffic goes. + +### AI Gateway + +[AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) is a Premium Coder feature that provides centralized LLM proxy management, auditing, and attribution. Requires Coder >= 2.30.0. + +Pool speaks to OpenAI-compatible endpoints through `POOLSIDE_STANDALONE_BASE_URL`. Set `enable_ai_gateway = true` to point Pool at Coder's OpenAI-compatible AI Gateway endpoint (`/api/v2/ai-gateway/openai/v1`) and authenticate with the workspace owner's Coder session token. Coder then governs auth and routing centrally: developers never handle a provider key, and every request is attributed and audited. + +```tf +module "pool" { + source = "registry.coder.com/coder-labs/pool/coder" + version = "0.1.0" + agent_id = coder_agent.main.id + + enable_ai_gateway = true + model = "gpt-5" +} +``` + +> [!CAUTION] +> `enable_ai_gateway = true` is mutually exclusive with `poolside_api_key` and `standalone_base_url`. AI Gateway supplies both the endpoint and authentication. + +### Agent Firewall + +[Agent Firewall](https://coder.com/docs/ai-coder/agent-firewall) enforces a network egress allowlist around an agent so Pool can only reach approved destinations. Install the [`agent-firewall`](https://registry.coder.com/modules/coder/agent-firewall) module and run `pool` through its wrapper to apply policy enforcement: + +```tf +module "pool" { + source = "registry.coder.com/coder-labs/pool/coder" + version = "0.1.0" + agent_id = coder_agent.main.id + + poolside_api_key = var.poolside_api_key +} + +module "agent-firewall" { + source = "registry.coder.com/coder/agent-firewall/coder" + version = "0.0.3" + agent_id = coder_agent.main.id +} + +resource "coder_app" "pool" { + agent_id = coder_agent.main.id + slug = "pool" + display_name = "Pool (Agent Firewall)" + icon = "/icon/poolside.svg" + command = <<-EOT + #!/bin/bash + set -e + exec tmux new-session -A -s pool \ + '"${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- pool' + EOT +} +``` + +Add Pool's runtime endpoints (see [Network access](#network-access-and-air-gapped-environments)) to the Agent Firewall allowlist so requests are not blocked. + +## OpenAI-compatible endpoints + +Use `standalone_base_url` to configure another OpenAI-compatible proxy or local inference server. Provide `poolside_api_key` when that endpoint requires authentication; `model` selects the model if the endpoint does not provide a default. + +```tf +module "pool" { + source = "registry.coder.com/coder-labs/pool/coder" + version = "0.1.0" + agent_id = coder_agent.main.id + + poolside_api_key = var.gateway_api_key + standalone_base_url = "https://gateway.example.com/v1" + model = "my-coding-model" +} +``` + +For a Poolside deployment, use `poolside_api_url` to set `POOLSIDE_API_URL` instead. + +## Existing installations + +Set `install_pool = false` when Pool CLI is already present in your workspace image. Configure `pool_binary_path` if the binary is in a directory other than `~/.local/bin`. + +## Network access and air-gapped environments + +The table lists every external endpoint the module contacts, so you can pre-approve them in an allowlist or mirror them in a restricted network. + +| Phase | Endpoint | When | How to override | +| ------- | --------------------------------------------------------------------------- | ----------------------------------------- | ---------------------------------------------------------------------- | +| Install | `https://downloads.poolside.ai/pool/install.sh` (+ the binaries it fetches) | `install_pool = true` (default) | `install_url` (script), or `install_pool = false` to skip entirely | +| Runtime | Poolside's hosted API | Default, when no endpoint override is set | `poolside_api_url` for a Poolside deployment | +| Runtime | Coder deployment `access_url` (`/api/v2/ai-gateway/...`) | `enable_ai_gateway = true` | Already internal to your deployment | +| Runtime | `standalone_base_url` | When set | Point at an internal OpenAI-compatible proxy or local inference server | + +For restricted or air-gapped workspaces: + +- **Mirror the installer.** Set `install_url` to an internal copy of `install.sh` (and mirror the binaries it downloads), or bake Pool into the image and set `install_pool = false`. +- **Keep model traffic internal.** Route requests through AI Gateway (`enable_ai_gateway = true`) or an internal OpenAI-compatible endpoint (`standalone_base_url`) instead of Poolside's hosted API. +- **Enforce egress.** Combine with [Agent Firewall](#agent-firewall) to allow only the endpoints above. + +```tf +module "pool" { + source = "registry.coder.com/coder-labs/pool/coder" + version = "0.1.0" + agent_id = coder_agent.main.id + + install_url = "https://artifacts.internal.example.com/pool/install.sh" + standalone_base_url = "https://llm.internal.example.com/v1" + poolside_api_key = var.gateway_api_key +} +``` + +## Serialize a downstream `coder_script` after installation + +The `scripts` output is an ordered list of `coder exp sync` names created by the module. + +```tf +resource "coder_script" "verify_pool" { + agent_id = coder_agent.main.id + display_name = "Verify Pool CLI" + run_on_start = true + script = <<-EOT + #!/bin/bash + set -euo pipefail + trap 'coder exp sync complete verify-pool' EXIT + coder exp sync want verify-pool ${join(" ", module.pool.scripts)} + coder exp sync start verify-pool + + pool --version + EOT +} +``` + +## Troubleshooting + +The module's script logs are under `~/.coder-modules/coder-labs/pool/logs/`. + +```bash +cat ~/.coder-modules/coder-labs/pool/logs/install.log +cat ~/.coder-modules/coder-labs/pool/logs/pre_install.log +cat ~/.coder-modules/coder-labs/pool/logs/post_install.log +``` + +## References + +- [Pool CLI documentation](https://docs.poolside.ai/cli/pool) +- [Pool CLI installation](https://docs.poolside.ai/cli/install) +- [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) +- [Agent Firewall](https://coder.com/docs/ai-coder/agent-firewall) diff --git a/registry/coder-labs/modules/pool/main.tf b/registry/coder-labs/modules/pool/main.tf new file mode 100644 index 000000000..caf1732cf --- /dev/null +++ b/registry/coder-labs/modules/pool/main.tf @@ -0,0 +1,181 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.12" + } + } +} + +variable "agent_id" { + description = "The ID of a Coder agent." + type = string +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +variable "icon" { + description = "The icon to use for the app." + type = string + default = "/icon/poolside.svg" +} + +variable "pre_install_script" { + description = "Custom script to run before installing Pool CLI." + type = string + default = null +} + +variable "post_install_script" { + description = "Custom script to run after installing Pool CLI." + type = string + default = null +} + +variable "install_pool" { + description = "Whether to install Pool CLI. Set false when Pool is already installed in the workspace image." + type = bool + default = true +} + +variable "pool_binary_path" { + description = "Directory containing the Pool CLI binary. The installer uses this directory when install_pool is true." + type = string + default = "$HOME/.local/bin" +} + +variable "install_url" { + description = "URL of the Pool CLI install script. Override to install from an internal mirror or artifact store in restricted or air-gapped environments." + type = string + default = "https://downloads.poolside.ai/pool/install.sh" + + validation { + condition = can(regex("^https?://", var.install_url)) + error_message = "install_url must be an http(s) URL." + } +} + +variable "poolside_api_key" { + description = "Poolside API key passed to Pool CLI via POOLSIDE_API_KEY." + type = string + default = "" + sensitive = true +} + +variable "poolside_api_url" { + description = "Optional Poolside deployment API URL passed to Pool CLI via POOLSIDE_API_URL." + type = string + default = "" + + validation { + condition = var.poolside_api_url == "" || can(regex("^https?://", var.poolside_api_url)) + error_message = "poolside_api_url must be an http(s) URL when set." + } +} + +variable "standalone_base_url" { + description = "Optional OpenAI-compatible API base URL passed to Pool CLI via POOLSIDE_STANDALONE_BASE_URL. Use this for a non-Coder gateway or local inference server." + type = string + default = "" + + validation { + condition = !(var.enable_ai_gateway && var.standalone_base_url != "") + error_message = "standalone_base_url cannot be provided when enable_ai_gateway is true." + } + + validation { + condition = var.standalone_base_url == "" || can(regex("^https?://", var.standalone_base_url)) + error_message = "standalone_base_url must be an http(s) URL when set." + } +} + +variable "model" { + description = "Optional model passed to Pool CLI via POOLSIDE_STANDALONE_MODEL when using an OpenAI-compatible API or Coder AI Gateway." + type = string + default = "" +} + +variable "enable_ai_gateway" { + description = "Use Coder AI Gateway through its OpenAI-compatible endpoint. https://coder.com/docs/ai-coder/ai-gateway" + type = bool + default = false + + validation { + condition = !(var.enable_ai_gateway && var.poolside_api_key != "") + error_message = "poolside_api_key cannot be provided when enable_ai_gateway is true. AI Gateway automatically authenticates Pool CLI using Coder credentials." + } +} + +resource "coder_env" "poolside_api_key" { + count = var.poolside_api_key != "" ? 1 : 0 + agent_id = var.agent_id + name = "POOLSIDE_API_KEY" + value = var.poolside_api_key +} + +resource "coder_env" "poolside_api_url" { + count = var.poolside_api_url != "" ? 1 : 0 + agent_id = var.agent_id + name = "POOLSIDE_API_URL" + value = var.poolside_api_url +} + +resource "coder_env" "standalone_base_url" { + count = var.standalone_base_url != "" ? 1 : 0 + agent_id = var.agent_id + name = "POOLSIDE_STANDALONE_BASE_URL" + value = var.standalone_base_url +} + +resource "coder_env" "model" { + count = var.model != "" ? 1 : 0 + agent_id = var.agent_id + name = "POOLSIDE_STANDALONE_MODEL" + value = var.model +} + +# Pool CLI uses POOLSIDE_API_KEY for OpenAI-compatible API authentication. +# Coder AI Gateway accepts the workspace owner's session token as a bearer token. +resource "coder_env" "ai_gateway_session_token" { + count = var.enable_ai_gateway ? 1 : 0 + agent_id = var.agent_id + name = "POOLSIDE_API_KEY" + value = data.coder_workspace_owner.me.session_token +} + +resource "coder_env" "ai_gateway_base_url" { + count = var.enable_ai_gateway ? 1 : 0 + agent_id = var.agent_id + name = "POOLSIDE_STANDALONE_BASE_URL" + value = "${trimsuffix(data.coder_workspace.me.access_url, "/")}/api/v2/ai-gateway/openai/v1" +} + +locals { + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL_POOL = tostring(var.install_pool) + ARG_POOL_BINARY_PATH = var.pool_binary_path + ARG_INSTALL_URL = var.install_url + }) +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = "$HOME/.coder-modules/coder-labs/pool" + display_name_prefix = "Pool CLI" + icon = var.icon + pre_install_script = var.pre_install_script + install_script = local.install_script + post_install_script = var.post_install_script +} + +output "scripts" { + description = "Ordered list of coder exp sync names produced by this module, in run order." + value = module.coder_utils.scripts +} diff --git a/registry/coder-labs/modules/pool/main.tftest.hcl b/registry/coder-labs/modules/pool/main.tftest.hcl new file mode 100644 index 000000000..bb4755697 --- /dev/null +++ b/registry/coder-labs/modules/pool/main.tftest.hcl @@ -0,0 +1,163 @@ +run "test_pool_defaults" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = var.install_pool == true + error_message = "install_pool should default to true" + } + + assert { + condition = var.pool_binary_path == "$HOME/.local/bin" + error_message = "pool_binary_path should default to $HOME/.local/bin" + } + + assert { + condition = var.install_url == "https://downloads.poolside.ai/pool/install.sh" + error_message = "install_url should default to the official Poolside installer" + } +} + +run "test_install_url_override" { + command = plan + + variables { + agent_id = "test-agent" + install_url = "https://mirror.example.com/pool/install.sh" + } + + assert { + condition = var.install_url == "https://mirror.example.com/pool/install.sh" + error_message = "install_url should use the supplied mirror URL" + } +} + +run "test_install_url_rejects_invalid" { + command = plan + + variables { + agent_id = "test-agent" + install_url = "downloads.poolside.ai/pool/install.sh" + } + + expect_failures = [var.install_url] +} + +run "test_standalone_base_url_rejects_invalid" { + command = plan + + variables { + agent_id = "test-agent" + standalone_base_url = "gateway.example.com/v1" + } + + expect_failures = [var.standalone_base_url] +} + +run "test_pool_with_api_key" { + command = plan + + variables { + agent_id = "test-agent" + poolside_api_key = "test-key" + } + + assert { + condition = coder_env.poolside_api_key[0].value == "test-key" + error_message = "POOLSIDE_API_KEY should use the supplied key" + } +} + +run "test_standalone_endpoint" { + command = plan + + variables { + agent_id = "test-agent" + standalone_base_url = "https://gateway.example.com/v1" + model = "test-model" + } + + assert { + condition = coder_env.standalone_base_url[0].value == "https://gateway.example.com/v1" + error_message = "POOLSIDE_STANDALONE_BASE_URL should use the supplied endpoint" + } + + assert { + condition = coder_env.model[0].value == "test-model" + error_message = "POOLSIDE_STANDALONE_MODEL should use the supplied model" + } +} + +run "test_ai_gateway_enabled" { + command = plan + + variables { + agent_id = "test-agent" + enable_ai_gateway = true + model = "gpt-5" + } + + override_data { + target = data.coder_workspace_owner.me + values = { + session_token = "mock-session-token" + } + } + + assert { + condition = coder_env.ai_gateway_session_token[0].value == data.coder_workspace_owner.me.session_token + error_message = "AI Gateway should use the workspace owner's session token" + } + + assert { + condition = coder_env.ai_gateway_base_url[0].name == "POOLSIDE_STANDALONE_BASE_URL" + error_message = "AI Gateway should configure Pool's OpenAI-compatible endpoint" + } + + assert { + condition = length(coder_env.poolside_api_key) == 0 + error_message = "A direct Poolside API key should not be set when AI Gateway is enabled" + } +} + +run "test_ai_gateway_rejects_api_key" { + command = plan + + variables { + agent_id = "test-agent" + enable_ai_gateway = true + poolside_api_key = "test-key" + } + + expect_failures = [var.enable_ai_gateway] +} + +run "test_ai_gateway_rejects_standalone_endpoint" { + command = plan + + variables { + agent_id = "test-agent" + enable_ai_gateway = true + standalone_base_url = "https://gateway.example.com/v1" + } + + expect_failures = [var.standalone_base_url] +} + +run "test_scripts_output" { + command = plan + + variables { + agent_id = "test-agent" + pre_install_script = "echo pre" + post_install_script = "echo post" + } + + assert { + condition = length(output.scripts) == 3 + error_message = "scripts should include pre-install, install, and post-install scripts" + } +} diff --git a/registry/coder-labs/modules/pool/scripts/install.sh.tftpl b/registry/coder-labs/modules/pool/scripts/install.sh.tftpl new file mode 100644 index 000000000..7f06eaee2 --- /dev/null +++ b/registry/coder-labs/modules/pool/scripts/install.sh.tftpl @@ -0,0 +1,62 @@ +#!/bin/bash + +set -euo pipefail + +ARG_INSTALL_POOL='${ARG_INSTALL_POOL}' +ARG_POOL_BINARY_PATH='${ARG_POOL_BINARY_PATH}' +ARG_INSTALL_URL='${ARG_INSTALL_URL}' +ARG_POOL_BINARY_PATH="$${ARG_POOL_BINARY_PATH/#\~/$HOME}" +ARG_POOL_BINARY_PATH="$${ARG_POOL_BINARY_PATH//\$HOME/$HOME}" + +add_path_to_shell_profiles() { + local path_dir="$1" + + for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do + if [ -f "$${profile}" ] && ! grep -qF "$${path_dir}" "$${profile}" 2> /dev/null; then + echo "export PATH=\"$${path_dir}:\$PATH\"" >> "$${profile}" + fi + done +} + +ensure_pool_in_path() { + local pool_bin="" + if command -v pool > /dev/null 2>&1; then + pool_bin=$(command -v pool) + elif [ -x "$${ARG_POOL_BINARY_PATH}/pool" ]; then + pool_bin="$${ARG_POOL_BINARY_PATH}/pool" + fi + + if [ -z "$${pool_bin}" ]; then + echo "Warning: Could not find the pool binary" + return + fi + + local pool_dir + pool_dir=$(dirname "$${pool_bin}") + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/pool" ]; then + ln -s "$${pool_bin}" "$${CODER_SCRIPT_BIN_DIR}/pool" + fi + + add_path_to_shell_profiles "$${pool_dir}" +} + +install_pool() { + if [ "$${ARG_INSTALL_POOL}" != "true" ]; then + echo "Skipping Pool CLI installation as configured." + ensure_pool_in_path + return + fi + + echo "Installing Pool CLI" + mkdir -p "$${ARG_POOL_BINARY_PATH}" + export POOL_INSTALL_ACCEPT_EULA=1 + export POOL_INSTALL_DIR="$${ARG_POOL_BINARY_PATH}" + export POOL_INSTALL_UPDATE_PATH=1 + curl -fsSL "$${ARG_INSTALL_URL}" | sh + + export PATH="$${ARG_POOL_BINARY_PATH}:$PATH" + echo "Installed Pool CLI: $(pool --version)" + ensure_pool_in_path +} + +install_pool