diff --git a/.icons/omnigent.svg b/.icons/omnigent.svg new file mode 100644 index 000000000..b70b60659 --- /dev/null +++ b/.icons/omnigent.svg @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/registry/coder-labs/modules/omnigent/README.md b/registry/coder-labs/modules/omnigent/README.md new file mode 100644 index 000000000..710189b90 --- /dev/null +++ b/registry/coder-labs/modules/omnigent/README.md @@ -0,0 +1,144 @@ +--- +display_name: Omnigent +icon: ../../../../.icons/omnigent.svg +description: Run a private Omnigent multi-agent coding server in your workspace. +verified: false +tags: [agent, omnigent, ai, multi-agent] +--- + +# Omnigent + +Run a private [Omnigent](https://github.com/omnigent-dev) multi-agent coding orchestrator server inside your Coder workspace. Each workspace gets its own isolated Omnigent instance with a stable, derived admin password — no shared credentials, no manual password management. + +The module installs Omnigent via the [official install script](https://omnigent.ai/install.sh), starts the server on a configurable port, waits for the health endpoint, and registers the local workspace as a host. The admin password is derived from the workspace ID at runtime and never stored in Terraform state. + +```tf +module "omnigent" { + source = "registry.coder.com/coder-labs/omnigent/coder" + version = "1.0.0" + agent_id = coder_agent.main.id +} +``` + +## Examples + +### With a custom port + +```tf +module "omnigent" { + source = "registry.coder.com/coder-labs/omnigent/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + port = 7878 +} +``` + +### With AI tools (Omnigent + Claude Code + Codex) + +Compose Omnigent alongside other AI agent modules to create a full multi-agent workspace. This example authenticates Claude Code and Codex through Coder AI Gateway. + +```tf +module "codex" { + source = "registry.coder.com/coder-labs/codex/coder" + version = "5.0.0" + + agent_id = coder_agent.main.id + enable_ai_gateway = true +} + +module "claude_code" { + source = "registry.coder.com/coder/claude-code/coder" + version = ">= 4.0.0" + + agent_id = coder_agent.main.id + enable_ai_gateway = true +} + +module "omnigent" { + source = "registry.coder.com/coder-labs/omnigent/coder" + version = "1.0.0" + + agent_id = coder_agent.main.id +} +``` + +### Policies (server-wide) + +```tf +module "omnigent" { + source = "registry.coder.com/coder-labs/omnigent/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + + server_config = <<-YAML + policies: + cap_tool_calls: + type: function + handler: omnigent.policies.builtins.safety.max_tool_calls_per_session + factory_params: + limit: 50 + require_approval: + type: function + handler: omnigent.policies.builtins.safety.ask_on_os_tools + YAML +} +``` + +### Custom agents + +```tf +module "omnigent" { + source = "registry.coder.com/coder-labs/omnigent/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + + agents = [ + { + name = "reviewer" + content = <<-YAML + name: reviewer + instructions: You are an expert code reviewer. Focus on correctness, security, and clarity. + executor: + harness: claude-sdk + model: claude-sonnet-4-5 + YAML + } + ] +} +``` + +### Bring-your-own config file + +```tf +module "omnigent" { + source = "registry.coder.com/coder-labs/omnigent/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + server_config_path = "/home/coder/.omnigent/server_config.yaml" +} +``` + +## Troubleshooting + +Script logs are written to `~/.coder-modules/coder-labs/omnigent/logs/`. If the Omnigent app shows as unhealthy or the server fails to start, check: + +```bash +cat ~/.coder-modules/coder-labs/omnigent/logs/server.log +cat ~/.coder-modules/coder-labs/omnigent/logs/start.log +cat ~/.coder-modules/coder-labs/omnigent/logs/install.log +cat ~/.coder-modules/coder-labs/omnigent/logs/host.log +``` + +The health endpoint is available at `http://localhost:/health`. You can check it directly: + +```bash +curl -sf http://localhost:6767/health && echo "healthy" || echo "not ready" +``` + +### Finding the admin password + +The admin password is derived from the workspace ID at runtime. To retrieve it inside the workspace: + +```bash +echo -n "$CODER_WORKSPACE_ID" | tr -d '-' | cut -c1-16 +``` diff --git a/registry/coder-labs/modules/omnigent/main.tf b/registry/coder-labs/modules/omnigent/main.tf new file mode 100644 index 000000000..7b758a3d1 --- /dev/null +++ b/registry/coder-labs/modules/omnigent/main.tf @@ -0,0 +1,165 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.13" + } + } +} + +variable "agent_id" { + description = "The ID of a Coder agent." + type = string +} + +variable "icon" { + description = "Icon for Omnigent scripts and app." + type = string + default = "../../../../.icons/omnigent.svg" +} + +variable "port" { + description = "Port the Omnigent server listens on inside the workspace." + type = number + default = 6767 + validation { + condition = var.port > 1024 && var.port < 65536 + error_message = "port must be between 1025 and 65535." + } +} + +variable "omnigent_version" { + description = "Omnigent version to install. 'latest' installs the newest release." + type = string + default = "latest" +} + +variable "share" { + description = "Coder app share level." + type = string + default = "owner" + validation { + condition = contains(["owner", "authenticated", "public"], var.share) + error_message = "share must be one of: owner, authenticated, public." + } +} + +variable "order" { + description = "Order for the Omnigent app in the Coder UI." + type = number + default = null +} + +variable "server_config" { + description = "Inline server_config.yaml content for the Omnigent server. Supports policies, policy_modules, admins, and allowed_domains keys. When set, written to the module directory and passed as -c to the server. Mutually exclusive with server_config_path." + type = string + default = null + validation { + condition = !(var.server_config != null && var.server_config_path != null) + error_message = "Only one of server_config or server_config_path may be set." + } +} + +variable "server_config_path" { + description = "Path to an existing server_config.yaml in the workspace. When set, passed directly as -c to the server; no config file is written by this module. Mutually exclusive with server_config." + type = string + default = null +} + +variable "agents" { + description = "Custom agent YAML definitions to pre-register at server startup. Each entry is written to the module directory and passed as --agent flags." + type = list(object({ + name = string + content = string + })) + default = [] +} + +variable "pre_install_script" { + description = "Custom script to run before installing Omnigent." + type = string + default = null +} + +variable "post_install_script" { + description = "Custom script to run after installing Omnigent." + type = string + default = null +} + +locals { + module_dir = "$HOME/.coder-modules/coder-labs/omnigent" + server_config_file = "${local.module_dir}/config/server.yaml" + agents_dir = "${local.module_dir}/agents" + + effective_server_config_path = ( + var.server_config_path != null ? var.server_config_path : + var.server_config != null ? local.server_config_file : + null + ) + + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_OMNIGENT_VERSION_B64 = var.omnigent_version != "latest" ? base64encode(var.omnigent_version) : "" + ARG_PORT = tostring(var.port) + ARG_WRITE_SERVER_CONFIG = tostring(var.server_config != null) + ARG_SERVER_CONFIG_B64 = var.server_config != null ? base64encode(var.server_config) : "" + ARG_SERVER_CONFIG_FILE = local.server_config_file + ARG_SERVER_CONFIG_DIR = "${local.module_dir}/config" + ARG_AGENTS_B64 = length(var.agents) > 0 ? base64encode(join("\n", [for a in var.agents : "${a.name}\t${base64encode(a.content)}"])) : "" + ARG_AGENTS_DIR = local.agents_dir + }) + + start_script = templatefile("${path.module}/scripts/start.sh.tftpl", { + ARG_PORT = tostring(var.port) + ARG_EFFECTIVE_SERVER_CONFIG_PATH = local.effective_server_config_path != null ? local.effective_server_config_path : "" + ARG_AGENTS_DIR = local.agents_dir + }) +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + + agent_id = var.agent_id + module_directory = local.module_dir + display_name_prefix = "Omnigent" + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script + start_script = local.start_script +} + +resource "coder_app" "omnigent" { + agent_id = var.agent_id + slug = "omnigent" + display_name = "Omnigent" + url = "http://localhost:${var.port}" + icon = var.icon + subdomain = true + share = var.share + order = var.order + + healthcheck { + url = "http://localhost:${var.port}/health" + interval = 15 + threshold = 3 + } +} + +output "scripts" { + description = "Ordered list of coder exp sync names produced by this module, in run order." + value = module.coder_utils.scripts +} + +output "port" { + description = "Port the Omnigent server is listening on." + value = var.port +} + +output "server_config_path" { + description = "Effective path to the server config file, or empty string if no config is used." + value = local.effective_server_config_path != null ? local.effective_server_config_path : "" +} diff --git a/registry/coder-labs/modules/omnigent/main.tftest.hcl b/registry/coder-labs/modules/omnigent/main.tftest.hcl new file mode 100644 index 000000000..341703165 --- /dev/null +++ b/registry/coder-labs/modules/omnigent/main.tftest.hcl @@ -0,0 +1,239 @@ +run "test_defaults" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = var.port == 6767 + error_message = "port should default to 6767" + } + + assert { + condition = var.share == "owner" + error_message = "share should default to owner" + } + + assert { + condition = var.omnigent_version == "latest" + error_message = "omnigent_version should default to latest" + } + + assert { + condition = coder_app.omnigent.url == "http://localhost:6767" + error_message = "coder_app url should use default port 6767" + } + + assert { + condition = coder_app.omnigent.share == "owner" + error_message = "coder_app share should default to owner" + } +} + +run "test_custom_port" { + command = plan + + variables { + agent_id = "test-agent" + port = 8080 + } + + assert { + condition = var.port == 8080 + error_message = "port should be set to 8080" + } + + assert { + condition = coder_app.omnigent.url == "http://localhost:8080" + error_message = "coder_app url should use custom port 8080" + } +} + +run "test_custom_share" { + command = plan + + variables { + agent_id = "test-agent" + share = "authenticated" + } + + assert { + condition = var.share == "authenticated" + error_message = "share should be set to authenticated" + } + + assert { + condition = coder_app.omnigent.share == "authenticated" + error_message = "coder_app share should be authenticated" + } +} + +run "test_custom_version" { + command = plan + + variables { + agent_id = "test-agent" + omnigent_version = "0.1.0" + } + + assert { + condition = var.omnigent_version == "0.1.0" + error_message = "omnigent_version should be set to 0.1.0" + } +} + +run "test_scripts_output" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = length(output.scripts) > 0 + error_message = "scripts output should be non-empty" + } +} + +run "test_install_script_installs_uv" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = strcontains(local.install_script, "https://astral.sh/uv/install.sh") + error_message = "install script should install uv when it is missing" + } + + assert { + condition = strcontains(local.install_script, "command -v uv") + error_message = "install script should check whether uv is available" + } +} + +run "test_start_script_backgrounds_host" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = strcontains(local.start_script, "nohup omnigent host") + error_message = "start script should run the Omnigent host in the background" + } + + assert { + condition = strcontains(local.start_script, "host.log") + error_message = "start script should write Omnigent host logs to host.log" + } +} + +run "test_port_output" { + command = plan + + variables { + agent_id = "test-agent" + port = 7777 + } + + assert { + condition = output.port == 7777 + error_message = "port output should match the configured port" + } +} + +run "test_invalid_port_low" { + command = plan + + variables { + agent_id = "test-agent" + port = 80 + } + + expect_failures = [var.port] +} + +run "test_invalid_port_high" { + command = plan + + variables { + agent_id = "test-agent" + port = 65536 + } + + expect_failures = [var.port] +} + +run "test_invalid_share" { + command = plan + + variables { + agent_id = "test-agent" + share = "invalid" + } + + expect_failures = [var.share] +} + +run "test_server_config" { + command = plan + + variables { + agent_id = "test-agent" + server_config = "policies: {}" + } + + assert { + condition = var.server_config == "policies: {}" + error_message = "server_config should be set" + } +} + +run "test_server_config_path" { + command = plan + + variables { + agent_id = "test-agent" + server_config_path = "/home/coder/.omnigent/server.yaml" + } + + assert { + condition = output.server_config_path == "/home/coder/.omnigent/server.yaml" + error_message = "server_config_path output should match the provided path" + } +} + +run "test_server_config_mutual_exclusion" { + command = plan + + variables { + agent_id = "test-agent" + server_config = "policies: {}" + server_config_path = "/home/coder/.omnigent/server.yaml" + } + + expect_failures = [var.server_config] +} + +run "test_agents" { + command = plan + + variables { + agent_id = "test-agent" + agents = [ + { + name = "reviewer" + content = "name: reviewer\ninstructions: You are a reviewer." + } + ] + } + + assert { + condition = length(var.agents) == 1 + error_message = "agents should have one entry" + } +} diff --git a/registry/coder-labs/modules/omnigent/scripts/install.sh.tftpl b/registry/coder-labs/modules/omnigent/scripts/install.sh.tftpl new file mode 100644 index 000000000..b042b025b --- /dev/null +++ b/registry/coder-labs/modules/omnigent/scripts/install.sh.tftpl @@ -0,0 +1,74 @@ +#!/bin/bash +set -euo pipefail + +BOLD='\033[0;1m' + +ARG_OMNIGENT_VERSION=$(echo -n '${ARG_OMNIGENT_VERSION_B64}' | base64 -d) +# Empty = latest; non-empty = pinned version +ARG_PORT='${ARG_PORT}' +ARG_WRITE_SERVER_CONFIG='${ARG_WRITE_SERVER_CONFIG}' +ARG_SERVER_CONFIG=$(echo -n '${ARG_SERVER_CONFIG_B64}' | base64 -d) +ARG_SERVER_CONFIG_FILE='${ARG_SERVER_CONFIG_FILE}' +ARG_SERVER_CONFIG_DIR='${ARG_SERVER_CONFIG_DIR}' +ARG_AGENTS=$(echo -n '${ARG_AGENTS_B64}' | base64 -d) +ARG_AGENTS_DIR='${ARG_AGENTS_DIR}' + +export PATH="$${HOME}/.local/bin:$${PATH}" + +echo "--------------------------------" +printf "omnigent_version: %s\n" "$${ARG_OMNIGENT_VERSION:-latest}" +printf "port: %s\n" "$${ARG_PORT}" +printf "write_server_config: %s\n" "$${ARG_WRITE_SERVER_CONFIG}" +echo "--------------------------------" + +if ! command -v curl >/dev/null 2>&1; then + echo "ERROR: curl is required to install uv and Omnigent." >&2 + exit 1 +fi + +if ! command -v uv >/dev/null 2>&1; then + printf "%s Installing uv\n" "$${BOLD}" + curl -LsSf https://astral.sh/uv/install.sh | sh + export PATH="$${HOME}/.local/bin:$${PATH}" +fi + +if ! command -v uv >/dev/null 2>&1; then + echo "ERROR: uv installation failed. Install from https://docs.astral.sh/uv/getting-started/installation/, then rerun." >&2 + exit 1 +fi + +printf "%s Found uv: %s\n" "$${BOLD}" "$(uv --version)" + +# Install omnigent via official installer +INSTALL_ARGS="--non-interactive" +if [ -n "$${ARG_OMNIGENT_VERSION}" ]; then + INSTALL_ARGS="$${INSTALL_ARGS} --version $${ARG_OMNIGENT_VERSION}" +fi +printf "%s Installing omnigent%s\n" "$${BOLD}" "$${ARG_OMNIGENT_VERSION:+ $${ARG_OMNIGENT_VERSION}}" +# shellcheck disable=SC2086 +curl -fsSL https://omnigent.ai/install.sh | sh -s -- $${INSTALL_ARGS} + +export PATH="$${HOME}/.local/bin:$${PATH}" + +printf "%s Installed omnigent: %s\n" "$${BOLD}" "$(omnigent --version)" + +# Configure client to point to the local server +omnigent config set server=http://localhost:$${ARG_PORT} + +# Write server config file if provided +if [ "$${ARG_WRITE_SERVER_CONFIG}" = "true" ]; then + mkdir -p "$${ARG_SERVER_CONFIG_DIR}" + printf "%s Writing server config to %s\n" "$${BOLD}" "$${ARG_SERVER_CONFIG_FILE}" + echo "$${ARG_SERVER_CONFIG}" > "$${ARG_SERVER_CONFIG_FILE}" +fi + +# Write agent YAML files +if [ -n "$${ARG_AGENTS}" ]; then + mkdir -p "$${ARG_AGENTS_DIR}" + while IFS=$'\t' read -r agent_name agent_content_b64; do + [ -z "$${agent_name}" ] && continue + agent_file="$${ARG_AGENTS_DIR}/$${agent_name}.yaml" + printf "%s Writing agent: %s -> %s\n" "$${BOLD}" "$${agent_name}" "$${agent_file}" + echo -n "$${agent_content_b64}" | base64 -d > "$${agent_file}" + done <<< "$${ARG_AGENTS}" +fi diff --git a/registry/coder-labs/modules/omnigent/scripts/start.sh.tftpl b/registry/coder-labs/modules/omnigent/scripts/start.sh.tftpl new file mode 100644 index 000000000..f569fbc6d --- /dev/null +++ b/registry/coder-labs/modules/omnigent/scripts/start.sh.tftpl @@ -0,0 +1,68 @@ +#!/bin/bash +set -euo pipefail + +export PATH="$${HOME}/.local/bin:$${PATH}" + +MODULE_DIR="$${HOME}/.coder-modules/coder-labs/omnigent" +START_LOG="$${MODULE_DIR}/logs/start.log" +SERVER_LOG="$${MODULE_DIR}/logs/server.log" +HOST_LOG="$${MODULE_DIR}/logs/host.log" +ARG_PORT='${ARG_PORT}' +ARG_EFFECTIVE_SERVER_CONFIG_PATH='${ARG_EFFECTIVE_SERVER_CONFIG_PATH}' +ARG_AGENTS_DIR='${ARG_AGENTS_DIR}' + +mkdir -p "$${MODULE_DIR}/logs" + +# Derive a stable admin password from the workspace ID (first 16 hex chars) +OMNIGENT_ADMIN_PASSWORD=$(echo -n "$${CODER_WORKSPACE_ID}" | tr -d '-' | cut -c1-16) + +if ! curl -sf "http://localhost:$${ARG_PORT}/health" &>/dev/null; then + echo "Starting Omnigent server on port $${ARG_PORT}..." + + # Build server flags + SERVER_FLAGS="--host 127.0.0.1 --port $${ARG_PORT} --no-open" + + # Add config file if set and present + if [ -n "$${ARG_EFFECTIVE_SERVER_CONFIG_PATH}" ] && [ -f "$${ARG_EFFECTIVE_SERVER_CONFIG_PATH}" ]; then + SERVER_FLAGS="$${SERVER_FLAGS} -c $${ARG_EFFECTIVE_SERVER_CONFIG_PATH}" + echo "Using server config: $${ARG_EFFECTIVE_SERVER_CONFIG_PATH}" + fi + + # Add pre-registered agent YAML files + if [ -d "$${ARG_AGENTS_DIR}" ]; then + for agent_file in "$${ARG_AGENTS_DIR}"/*.yaml; do + [ -f "$${agent_file}" ] || continue + SERVER_FLAGS="$${SERVER_FLAGS} --agent $${agent_file}" + echo "Registering agent: $${agent_file}" + done + fi + + export OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD="$${OMNIGENT_ADMIN_PASSWORD}" + # shellcheck disable=SC2086 + nohup omnigent server $${SERVER_FLAGS} >> "$${SERVER_LOG}" 2>&1 & +else + echo "Omnigent server already running on port $${ARG_PORT}, skipping start." +fi + +echo "Waiting for Omnigent server..." +for i in $(seq 1 90); do + if curl -sf "http://localhost:$${ARG_PORT}/health" &>/dev/null; then + echo "Omnigent server is ready." + break + fi + if [ "$${i}" -eq 90 ]; then + echo "ERROR: Omnigent server did not start within 90 seconds." >&2 + cat "$${SERVER_LOG}" >&2 || true + exit 1 + fi + sleep 1 +done + +# Register local workspace as a host. `omnigent host` stays attached, so run it +# in the background to let the Coder startup script finish. +if ! pgrep -f "[o]mnigent host" >/dev/null 2>&1; then + echo "Starting Omnigent host..." + nohup omnigent host "" >> "$${HOST_LOG}" 2>&1 & +else + echo "Omnigent host already running, skipping start." +fi diff --git a/registry/coder-labs/templates/omnigent-workspace/README.md b/registry/coder-labs/templates/omnigent-workspace/README.md new file mode 100644 index 000000000..5aab64742 --- /dev/null +++ b/registry/coder-labs/templates/omnigent-workspace/README.md @@ -0,0 +1,61 @@ +--- +display_name: Omnigent Workspace +icon: ../../../../.icons/omnigent.svg +description: Docker workspace with Omnigent, Claude Code, and Codex pre-installed. +verified: false +tags: [docker, omnigent, claude-code, codex, ai, multi-agent] +--- + +# Omnigent Workspace + +A Docker-based workspace that combines three AI agent modules: + +- **[Omnigent](https://registry.coder.com/modules/coder-labs/omnigent)** — private multi-agent coding orchestrator server +- **[Claude Code](https://registry.coder.com/modules/coder/claude-code)** — Anthropic's Claude in your terminal, authenticated through Coder AI Gateway +- **[Codex](https://registry.coder.com/modules/coder-labs/codex)** — OpenAI's Codex CLI, authenticated through Coder AI Gateway + +The template clones `https://github.com/coder/coder` into `/home/coder/workspace/coder` with the [Git Clone](https://registry.coder.com/modules/coder/git-clone) module, then configures Claude Code and Codex to use that repo as their trusted workdir. Each workspace runs its own isolated Omnigent server. The admin password is derived from the workspace ID at runtime and never stored in Terraform state. + +```tf +module "git_clone" { + source = "registry.coder.com/coder/git-clone/coder" + version = "2.0.1" + + agent_id = coder_agent.main.id + url = "https://github.com/coder/coder" + base_dir = "/home/coder/workspace" + folder_name = "coder" +} + +module "codex" { + source = "registry.coder.com/coder-labs/codex/coder" + version = "5.2.0" + + agent_id = coder_agent.main.id + workdir = module.git_clone.repo_dir + enable_ai_gateway = true +} + +module "claude_code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.2.0" + + agent_id = coder_agent.main.id + workdir = module.git_clone.repo_dir + enable_ai_gateway = true +} + +module "omnigent" { + source = "registry.coder.com/coder-labs/omnigent/coder" + version = "1.0.0" + + agent_id = coder_agent.main.id +} +``` + +## Prerequisites + +- Docker with `sysbox-runc` runtime installed on the Coder host +- Coder Premium with AI Gateway enabled + +The template checks for existing dependencies before installing missing packages. It installs `jq`, `tmux`, `bubblewrap`, and Node.js 22 when needed because the Claude Code module uses `jq` for setup, Codex needs a recent Node.js runtime, and Omnigent launches the Claude Code and Codex harnesses through local terminal sessions. diff --git a/registry/coder-labs/templates/omnigent-workspace/main.tf b/registry/coder-labs/templates/omnigent-workspace/main.tf new file mode 100644 index 000000000..53485db7c --- /dev/null +++ b/registry/coder-labs/templates/omnigent-workspace/main.tf @@ -0,0 +1,261 @@ +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.13" + } + docker = { + source = "kreuzwerker/docker" + version = "~> 4.0" + } + } +} + +provider "coder" {} +provider "docker" {} + +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} +data "coder_provisioner" "me" {} + +locals { + repo_ready_sync_name = "coder-labs-omnigent-git-clone" + + ai_tools_pre_install_commands = <<-EOT + missing_packages=() + command -v curl >/dev/null 2>&1 || missing_packages+=(curl) + command -v jq >/dev/null 2>&1 || missing_packages+=(jq) + command -v tmux >/dev/null 2>&1 || missing_packages+=(tmux) + command -v bwrap >/dev/null 2>&1 || missing_packages+=(bubblewrap) + + need_node=false + if ! command -v node >/dev/null 2>&1; then + need_node=true + elif ! node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' >/dev/null 2>&1; then + need_node=true + fi + + if [ "$${#missing_packages[@]}" -eq 0 ] && [ "$${need_node}" = false ]; then + exit 0 + fi + + if ! command -v apt-get >/dev/null 2>&1; then + echo "ERROR: missing required tools and apt-get is not available to install them." >&2 + printf 'Missing packages: %s\n' "$${missing_packages[*]:-none}" >&2 + printf 'Need Node.js 22+: %s\n' "$${need_node}" >&2 + exit 1 + fi + + ( + flock 9 + sudo apt-get update + + if [ "$${need_node}" = true ]; then + if ! command -v curl >/dev/null 2>&1; then + sudo apt-get install -y curl ca-certificates + fi + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - + sudo apt-get install -y nodejs + fi + + if [ "$${#missing_packages[@]}" -gt 0 ]; then + sudo apt-get install -y ca-certificates "$${missing_packages[@]}" + fi + ) 9>/tmp/coder-ai-tools-apt.lock + EOT + + codex_pre_install_script = <<-EOT + #!/bin/bash + set -euo pipefail + coder exp sync want coder-labs-codex-repo-ready ${local.repo_ready_sync_name} + coder exp sync start coder-labs-codex-repo-ready + coder exp sync complete coder-labs-codex-repo-ready + + ${local.ai_tools_pre_install_commands} + EOT + + claude_code_pre_install_script = <<-EOT + #!/bin/bash + set -euo pipefail + coder exp sync want coder-claude-code-repo-ready ${local.repo_ready_sync_name} + coder exp sync start coder-claude-code-repo-ready + coder exp sync complete coder-claude-code-repo-ready + + ${local.ai_tools_pre_install_commands} + EOT +} + +resource "coder_agent" "main" { + arch = data.coder_provisioner.me.arch + os = "linux" + startup_script = <<-EOT + #!/bin/bash + set -e + if [ ! -f ~/.init_done ]; then + cp -rT /etc/skel ~ 2>/dev/null || true + touch ~/.init_done + fi + EOT + + env = { + GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_AUTHOR_EMAIL = data.coder_workspace_owner.me.email + GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name) + GIT_COMMITTER_EMAIL = data.coder_workspace_owner.me.email + } + + metadata { + display_name = "CPU Usage" + key = "0_cpu_usage" + script = "coder stat cpu" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "RAM Usage" + key = "1_ram_usage" + script = "coder stat mem" + interval = 10 + timeout = 1 + } + + metadata { + display_name = "Home Disk" + key = "2_home_disk" + script = "coder stat disk --path $${HOME}" + interval = 60 + timeout = 1 + } +} + +module "git_clone" { + source = "registry.coder.com/coder/git-clone/coder" + version = "2.0.1" + + agent_id = coder_agent.main.id + url = "https://github.com/coder/coder" + base_dir = "/home/coder/workspace" + folder_name = "coder" + extra_args = ["--depth=1"] + + post_clone_script = <<-EOT + #!/bin/bash + set -euo pipefail + coder exp sync start ${local.repo_ready_sync_name} + coder exp sync complete ${local.repo_ready_sync_name} + EOT +} + +module "codex" { + source = "registry.coder.com/coder-labs/codex/coder" + version = "5.2.0" + + agent_id = coder_agent.main.id + workdir = module.git_clone.repo_dir + enable_ai_gateway = true + pre_install_script = local.codex_pre_install_script +} + +module "claude_code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.2.0" + + agent_id = coder_agent.main.id + workdir = module.git_clone.repo_dir + enable_ai_gateway = true + pre_install_script = local.claude_code_pre_install_script +} + +module "omnigent" { + source = "registry.coder.com/coder-labs/omnigent/coder" + version = "1.0.0" + + agent_id = coder_agent.main.id + + # Omnigent snapshots the host's available tools when the host starts. Wait for + # Claude Code and Codex to install and configure AI Gateway first, otherwise + # the Omnigent UI shows these harnesses as needing setup until the host restarts. + pre_install_script = <<-EOT + #!/bin/bash + set -euo pipefail + coder exp sync want coder-labs-omnigent-ai-tools ${join(" ", concat(module.claude_code.scripts, module.codex.scripts))} + coder exp sync start coder-labs-omnigent-ai-tools + coder exp sync complete coder-labs-omnigent-ai-tools + EOT +} + +resource "docker_volume" "home_volume" { + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-home" + lifecycle { + ignore_changes = all + } + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name_at_creation" + value = data.coder_workspace.me.name + } +} + +data "docker_registry_image" "workspace" { + name = "codercom/enterprise-base:ubuntu" +} + +resource "docker_image" "workspace" { + name = "codercom/enterprise-base@${data.docker_registry_image.workspace.sha256_digest}" + pull_triggers = [data.docker_registry_image.workspace.sha256_digest] + keep_locally = true +} + +resource "docker_container" "workspace" { + count = data.coder_workspace.me.start_count + image = docker_image.workspace.image_id + name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}" + hostname = lower(data.coder_workspace.me.name) + runtime = "sysbox-runc" + + entrypoint = ["sh", "-c", replace(coder_agent.main.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")] + + env = [ + "CODER_AGENT_TOKEN=${coder_agent.main.token}", + ] + + host { + host = "host.docker.internal" + ip = "host-gateway" + } + + volumes { + container_path = "/home/coder" + volume_name = docker_volume.home_volume.name + read_only = false + } + + labels { + label = "coder.owner" + value = data.coder_workspace_owner.me.name + } + labels { + label = "coder.owner_id" + value = data.coder_workspace_owner.me.id + } + labels { + label = "coder.workspace_id" + value = data.coder_workspace.me.id + } + labels { + label = "coder.workspace_name" + value = data.coder_workspace.me.name + } +}