diff --git a/registry/coder-labs/modules/kiro-crew/README.md b/registry/coder-labs/modules/kiro-crew/README.md new file mode 100644 index 000000000..300f6f973 --- /dev/null +++ b/registry/coder-labs/modules/kiro-crew/README.md @@ -0,0 +1,207 @@ +--- +display_name: Kiro Crew +description: Run the Kiro Crew gateway in your workspace and open its web dashboard from a self-authenticating Coder app. +icon: ../../../../.icons/kiro.svg +verified: false +tags: [agent, ai, kiro, aws, mcp] +--- + +# Kiro Crew + +Install [Kiro CLI](https://kiro.dev/cli/) and [Kiro Crew](https://github.com/kirodotdev/KiroCrew), run the `kirocrew gateway` in the workspace, and open the Kiro Crew web dashboard from a Coder app. + +```tf +module "kiro-crew" { + source = "registry.coder.com/coder-labs/kiro-crew/coder" + version = "0.0.1" + agent_id = coder_agent.main.id +} +``` + +The module adds two subdomain apps: + +- A visible **Kiro Crew** app that points at a loopback redirector. Each time you open it, the redirector mints a short-lived gateway token and redirects your browser to the dashboard with that token, so you land on an authenticated dashboard without running `kirocrew token`. +- A hidden `-dashboard` app that serves the dashboard and its websocket through Coder's subdomain proxy. + +The gateway rejects requests whose `Host` is not in its allowlist (`Host header not allowed.`). At startup, the module derives the dashboard app's origin from the agent's `VSCODE_PROXY_URI`, which follows the deployment's wildcard access URL. It also derives an origin for each hostname in `workspace_proxy_hostnames`. The module passes these origins to the gateway in `KIROCREW_CORS_ORIGINS` and sets `dashboard.url` to the primary origin. The redirector sends your browser to the dashboard on the same hostname you opened the app from, primary or proxy, if that origin is in the allowlist. Otherwise, it sends your browser to the primary origin. + +## Prerequisites + +- Your Coder deployment must serve [wildcard subdomain apps](https://coder.com/docs/admin/setup#wildcard-access-url). Both apps are subdomain apps. +- The workspace image needs `bash`, `curl`, `openssl`, and `sha256sum`. The Kiro Crew installer provisions its own managed Python via `uv`, so no system Python is required. Use `pre_install_script` to install missing packages. +- The Coder agent must support `coder exp sync`, which orders the install and start scripts. +- A Kiro account. See [Authentication](#authentication). + +## Examples + +### Install system prerequisites first + +```tf +module "kiro-crew" { + source = "registry.coder.com/coder-labs/kiro-crew/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + + pre_install_script = <<-EOT + #!/usr/bin/env bash + sudo apt-get update -qq + sudo apt-get install -y curl openssl unzip + EOT +} +``` + +### Add MCP servers + +`extra_mcp_json` is deep-merged into `~/.kiro/settings/mcp.json`. Keys you set replace existing keys with the same name; other existing servers are kept. + +```tf +module "kiro-crew" { + source = "registry.coder.com/coder-labs/kiro-crew/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + + extra_mcp_json = jsonencode({ + mcpServers = { + fetch = { + command = "uvx" + args = ["mcp-server-fetch"] + } + } + }) +} +``` + +### Workspace proxies + +Without configuration, a browser that opens Kiro Crew through a [workspace proxy](https://coder.com/docs/admin/networking/workspace-proxies) is redirected to the dashboard on the primary deployment's wildcard hostname. To keep dashboard traffic on the proxy, list each proxy's wildcard hostname, as shown in the `wildcard_hostname` field of `/api/v2/regions`: + +```tf +module "kiro-crew" { + source = "registry.coder.com/coder-labs/kiro-crew/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + + workspace_proxy_hostnames = [ + "*.apps.eu.example.com", + "*.apps.us.example.com", + ] +} +``` + +The gateway's allowlist is fixed when the workspace starts, so restart the workspace after you add a proxy. + +### Custom dashboard origin + +The module derives the dashboard origin from the agent's `VSCODE_PROXY_URI`. If that variable is not set, or you serve the dashboard from another origin, set the dashboard origin explicitly. The redirector uses it in place of the primary origin. The hidden app's slug is `-dashboard`. + +```tf +module "kiro-crew" { + source = "registry.coder.com/coder-labs/kiro-crew/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + dashboard_origin = "https://kirocrew-dashboard--${data.coder_workspace.me.name}--${data.coder_workspace_owner.me.name}.apps.example.com" +} +``` + +Use `allowed_origins` to trust additional origins, such as the Coder access URL, in the gateway's allowlist. + +### AWS Builder ID token from external auth + +```tf +data "coder_external_auth" "aws_builder_id" { + id = "aws-builder-id" +} + +module "kiro-crew" { + source = "registry.coder.com/coder-labs/kiro-crew/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + aws_builder_id_token = data.coder_external_auth.aws_builder_id.access_token +} +``` + +The token is exported to the workspace as `KIRO_AWS_BUILDER_ID_TOKEN` and is not rendered into any script. + +## Authentication + +The gateway starts with the workspace, but agents cannot call models until Kiro CLI is authenticated. Kiro credentials are stored under `$HOME`, so use a persistent home directory to keep them across restarts. + +- **Headless:** create a Coder user secret that sets `KIRO_API_KEY` before you start the workspace, for example `coder secret create kiro-api-key --env KIRO_API_KEY`. The gateway inherits it, and Kiro CLI authenticates without a browser. Generating API keys requires a Kiro subscription that supports them. +- **Interactive:** open a terminal, run `kiro-cli login`, and complete the device-code sign-in with AWS Builder ID or IAM Identity Center. Check the result with `kiro-cli whoami`. + +## Session continuity + +Kiro Crew is a long-running gateway, not a one-shot CLI. The `kirocrew gateway` process owns your sessions, chat transcripts, task state, and agent memory, so work continues across disconnects and restarts: + +- **Reconnecting resumes where you left off.** Closing the dashboard tab or losing your connection does not end a session. Reopening the **Kiro Crew** app reattaches to the same running gateway and its in-progress sessions and transcripts. +- **Sessions survive workspace restarts.** All gateway state lives under `$HOME` (`~/.kiro`). On a persistent home directory it is preserved across stop/start and rebuilds; the module's start script relaunches the gateway automatically on the next start, and your prior sessions are still there. + +Use a persistent home directory to retain sessions, transcripts, and Kiro credentials across restarts. For example, mount a persistent volume at `/home/coder`. + +## Restricted and air-gapped environments + +### Use an internal mirror for the installers + +By default the module downloads the Kiro CLI and Kiro Crew installers from the public Kiro endpoints. Point `kiro_cli_install_url` and `kirocrew_install_url` at an internal mirror or artifact store so the workspace never reaches the public internet for them: + +```tf +module "kiro-crew" { + source = "registry.coder.com/coder-labs/kiro-crew/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + + kiro_cli_install_url = "https://artifacts.example.com/kiro/cli-install.sh" + kirocrew_install_url = "https://artifacts.example.com/kiro/crew-cli.sh" +} +``` + +### Bring your own binaries (skip all downloads) + +`use_cached` defaults to `true`, which skips both installers when `kiro-cli` and `kirocrew` are already present (on `PATH` or in the managed venv at `~/.kiro/crew-venv`). Bake the binaries into your workspace image to run fully offline with no installer downloads at all: + +```Dockerfile +# In your workspace image +RUN curl -fsSL https://cli.kiro.dev/install | bash \ + && curl -fsSL https://download.crew.kiro.dev/cli.sh | sh +``` + +```tf +module "kiro-crew" { + source = "registry.coder.com/coder-labs/kiro-crew/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + use_cached = true # default; skips the installers when the binaries already exist +} +``` + +With the binaries baked in, neither installer endpoint is contacted. + +### Network access + +The module and the tools it runs contact these external endpoints. Nothing else is required by the module itself. + +| Phase | Endpoint | Purpose | Avoid by | +| ------------------ | ----------------------------------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------- | +| Install | `cli.kiro.dev` | Kiro CLI installer script | Set `kiro_cli_install_url`, or bake in the binary | +| Install | `download.crew.kiro.dev` | Kiro Crew installer script | Set `kirocrew_install_url`, or bake in the binary | +| Install | Astral (`uv` and managed CPython) | The Kiro Crew installer uses `uv` to provision its managed Python | Bake `~/.kiro/crew-python` and `~/.kiro/crew-venv` into the image | +| Install | `pypi.org`, `files.pythonhosted.org` | The Kiro Crew Python dependencies | Mirror PyPI, or bake in the `~/.kiro/crew-venv` | +| Install (optional) | Ubuntu package mirrors | Only if your `pre_install_script` installs packages | Pre-install the packages in the image | +| Runtime | Kiro service and model/embeddings backend | Kiro CLI sign-in and agent model/embeddings calls | Required for agents to call models; not overridable | + +For a fully air-gapped deployment: bake in the binaries and the managed venv, mirror PyPI, and confirm the workspace can still reach the Kiro model backend — agents cannot call models without it. + +## Troubleshooting + +Logs are written to `$HOME/.coder-modules/coder-labs/kiro-crew/logs/`: + +- `install.log` and `start.log` for the module scripts. +- `gateway.log` for the `kirocrew gateway` process. +- `redirector.log` for the token-minting redirector. + +The start script launches the gateway in a detached session and returns immediately, because a slow cold start could otherwise exceed the agent's script timeout. Readiness is reported by the app healthchecks, so the app can show as unhealthy for a short time after the workspace starts. While the gateway is starting, the Kiro Crew app responds with `503`; retry after a few seconds. + +- **`Host header not allowed.`:** the browser's origin is not in the gateway's allowlist. `start.log` lists the allowed origins. If you opened the dashboard through a workspace proxy, add its wildcard hostname to `workspace_proxy_hostnames`. Otherwise, set `dashboard_origin` or `allowed_origins` to the origin shown in the browser. +- **`ImportError: No module named 'aiohttp'` on gateway start:** the managed venv at `~/.kiro/crew-venv` was built with a foreign `PYTHONPATH`. The install script unsets `PYTHONPATH` and `PYTHONHOME`; remove `~/.kiro/crew-venv`, set `use_cached = false`, and restart the workspace. +- **Sandbox warnings:** Kiro Crew sandboxes agent subprocesses with a Linux user namespace. When the workspace cannot create one, which is common in Kubernetes pods, the gateway is configured with `agent.sandbox_allow_unsandboxed_exec` and the workspace is the isolation boundary. +- **Expected warnings:** `Vendored llama-cpp-python failed to import` (the gateway falls back to API-based embeddings) and `cgroup v2 scope enforcement unavailable` (no systemd user session in containers) do not affect the gateway. diff --git a/registry/coder-labs/modules/kiro-crew/kiro-crew.tftest.hcl b/registry/coder-labs/modules/kiro-crew/kiro-crew.tftest.hcl new file mode 100644 index 000000000..390f117c9 --- /dev/null +++ b/registry/coder-labs/modules/kiro-crew/kiro-crew.tftest.hcl @@ -0,0 +1,361 @@ +override_data { + target = data.coder_workspace.me + values = { + name = "dev" + access_url = "https://coder.example.com/" + } +} + +override_data { + target = data.coder_workspace_owner.me + values = { + name = "alice" + } +} + +run "defaults" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = local.module_directory == "$HOME/.coder-modules/coder-labs/kiro-crew" + error_message = "module_directory must follow the $HOME/.coder-modules// layout" + } + + assert { + condition = resource.coder_app.kiro_crew.url == "http://localhost:8898/" + error_message = "Visible app must point at the redirector on the default port" + } + + assert { + condition = resource.coder_app.kiro_crew.slug == "kirocrew" + error_message = "Visible app slug should default to 'kirocrew'" + } + + assert { + condition = resource.coder_app.kiro_crew.subdomain == true + error_message = "Visible app must be a subdomain app" + } + + assert { + condition = resource.coder_app.kiro_crew_dashboard.url == "http://localhost:8899/" + error_message = "Dashboard app must point at the gateway on the default port" + } + + assert { + condition = resource.coder_app.kiro_crew_dashboard.slug == "kirocrew-dashboard" + error_message = "Dashboard app slug should be '-dashboard'" + } + + assert { + condition = resource.coder_app.kiro_crew_dashboard.hidden == true + error_message = "Dashboard app must be hidden" + } + + assert { + condition = resource.coder_app.kiro_crew_dashboard.subdomain == true + error_message = "Dashboard app must be a subdomain app" + } + + assert { + condition = local.dashboard_origin == "https://kirocrew-dashboard--dev--alice.coder.example.com" + error_message = "Fallback dashboard origin should be derived from the access URL, workspace, and owner" + } + + assert { + condition = strcontains(local.start_script, "ARG_DASHBOARD_ORIGIN=$(echo -n '' | base64 -d)") + error_message = "Start script should receive an empty dashboard_origin override by default" + } + + assert { + condition = strcontains(local.start_script, "ARG_PROXY_HOSTNAMES=$(echo -n '' | base64 -d)") + error_message = "Start script should receive no workspace proxy hostnames by default" + } + + assert { + condition = output.kirocrew_url == "http://localhost:8899/" + error_message = "kirocrew_url output should use the gateway port" + } + + assert { + condition = length(resource.coder_env.aws_builder_id_token) == 0 + error_message = "No Builder ID env var should be created without a token" + } + + assert { + condition = strcontains(local.start_script, "ARG_FALLBACK_ORIGIN=$(echo -n '${base64encode(local.dashboard_origin)}' | base64 -d)") + error_message = "Start script should receive the fallback dashboard origin base64 encoded" + } + + assert { + condition = strcontains(local.install_script, "ARG_USE_CACHED='true'") + error_message = "Install script should use cached binaries by default" + } + + assert { + condition = strcontains(local.install_script, "ARG_EXTRA_MCP_JSON=$(echo -n '' | base64 -d)") + error_message = "Install script should receive an empty extra MCP value by default" + } + + assert { + condition = strcontains(local.install_script, "ARG_KIRO_CLI_INSTALL_URL='https://cli.kiro.dev/install'") + error_message = "Install script should default to the public Kiro CLI installer URL" + } + + assert { + condition = strcontains(local.install_script, "ARG_KIROCREW_INSTALL_URL='https://download.crew.kiro.dev/cli.sh'") + error_message = "Install script should default to the public Kiro Crew installer URL" + } +} + +run "install_urls_are_configurable" { + command = plan + + variables { + agent_id = "test-agent" + kiro_cli_install_url = "https://artifacts.example.com/kiro/cli-install.sh" + kirocrew_install_url = "https://artifacts.example.com/kiro/crew-cli.sh" + } + + assert { + condition = strcontains(local.install_script, "ARG_KIRO_CLI_INSTALL_URL='https://artifacts.example.com/kiro/cli-install.sh'") + error_message = "Install script should use the configured Kiro CLI installer URL" + } + + assert { + condition = strcontains(local.install_script, "ARG_KIROCREW_INSTALL_URL='https://artifacts.example.com/kiro/crew-cli.sh'") + error_message = "Install script should use the configured Kiro Crew installer URL" + } +} + +run "custom_ports_and_slug" { + command = plan + + variables { + agent_id = "test-agent" + port = 19899 + redirect_port = 19898 + slug = "crew" + } + + assert { + condition = resource.coder_app.kiro_crew.url == "http://localhost:19898/" + error_message = "Visible app must use redirect_port" + } + + assert { + condition = resource.coder_app.kiro_crew_dashboard.slug == "crew-dashboard" + error_message = "Dashboard app slug should follow the custom slug" + } + + assert { + condition = local.dashboard_origin == "https://crew-dashboard--dev--alice.coder.example.com" + error_message = "Dashboard origin should follow the custom slug" + } + + assert { + condition = strcontains(local.start_script, "ARG_PORT='19899'") && strcontains(local.start_script, "ARG_REDIRECT_PORT='19898'") + error_message = "Start script should receive the configured ports" + } +} + +run "healthchecks_use_configured_ports" { + command = apply + + variables { + agent_id = "test-agent" + port = 19899 + redirect_port = 19898 + } + + assert { + condition = one([for h in resource.coder_app.kiro_crew.healthcheck : h.url]) == "http://localhost:19898/healthz" + error_message = "Visible app healthcheck must use the redirector /healthz endpoint" + } + + assert { + condition = one([for h in resource.coder_app.kiro_crew_dashboard.healthcheck : h.url]) == "http://localhost:19899/health" + error_message = "Dashboard app healthcheck must use the gateway /health endpoint" + } +} + +run "allowed_origins_are_appended" { + command = plan + + variables { + agent_id = "test-agent" + allowed_origins = " https://coder.example.com,https://proxy.example.com " + } + + assert { + condition = strcontains(local.start_script, "ARG_EXTRA_ALLOWED_ORIGINS=$(echo -n '${base64encode("https://coder.example.com,https://proxy.example.com")}' | base64 -d)") + error_message = "Extra allowed origins should be trimmed and passed to the start script" + } +} + +run "workspace_proxy_hostnames_are_passed" { + command = plan + + variables { + agent_id = "test-agent" + workspace_proxy_hostnames = ["*--apps.eu.example.com", "https://*.apps.us.example.com:8443"] + } + + assert { + condition = strcontains(local.start_script, "ARG_PROXY_HOSTNAMES=$(echo -n '${base64encode("*--apps.eu.example.com\nhttps://*.apps.us.example.com:8443")}' | base64 -d)") + error_message = "Workspace proxy hostnames should be passed to the start script newline separated" + } +} + +run "invalid_workspace_proxy_hostname_is_rejected" { + command = plan + + variables { + agent_id = "test-agent" + workspace_proxy_hostnames = ["apps.example.com"] + } + + expect_failures = [var.workspace_proxy_hostnames] +} + +run "workspace_proxy_hostname_with_path_is_rejected" { + command = plan + + variables { + agent_id = "test-agent" + workspace_proxy_hostnames = ["*.apps.example.com/path"] + } + + expect_failures = [var.workspace_proxy_hostnames] +} + +run "dashboard_origin_override" { + command = plan + + variables { + agent_id = "test-agent" + dashboard_origin = "https://kirocrew-dashboard--dev--alice.apps.example.com" + } + + assert { + condition = local.dashboard_origin == "https://kirocrew-dashboard--dev--alice.apps.example.com" + error_message = "dashboard_origin should override the derived origin" + } + + assert { + condition = output.dashboard_origin == "https://kirocrew-dashboard--dev--alice.apps.example.com" + error_message = "dashboard_origin output should reflect the override" + } +} + +run "invalid_dashboard_origin_is_rejected" { + command = plan + + variables { + agent_id = "test-agent" + dashboard_origin = "https://apps.example.com/path" + } + + expect_failures = [var.dashboard_origin] +} + +run "invalid_extra_mcp_json_is_rejected" { + command = plan + + variables { + agent_id = "test-agent" + extra_mcp_json = "{not json" + } + + expect_failures = [var.extra_mcp_json] +} + +run "extra_mcp_json_is_base64_encoded" { + command = plan + + variables { + agent_id = "test-agent" + extra_mcp_json = "{\"mcpServers\":{\"demo\":{\"command\":\"echo\",\"args\":[\"it's\"]}}}" + } + + assert { + condition = strcontains(local.install_script, base64encode("{\"mcpServers\":{\"demo\":{\"command\":\"echo\",\"args\":[\"it's\"]}}}")) + error_message = "extra_mcp_json should be passed to the install script base64 encoded" + } +} + +run "http_access_url_keeps_scheme" { + command = plan + + override_data { + target = data.coder_workspace.me + values = { + name = "dev" + access_url = "http://localhost:3000" + } + } + + variables { + agent_id = "test-agent" + } + + assert { + condition = local.dashboard_origin == "http://kirocrew-dashboard--dev--alice.localhost:3000" + error_message = "Dashboard origin should keep the access URL scheme" + } +} + +run "builder_id_token_sets_env" { + command = apply + + variables { + agent_id = "test-agent" + aws_builder_id_token = "test-token" + } + + assert { + condition = length(resource.coder_env.aws_builder_id_token) == 1 + error_message = "Builder ID env var should be created when a token is set" + } + + assert { + condition = resource.coder_env.aws_builder_id_token[0].name == "KIRO_AWS_BUILDER_ID_TOKEN" + error_message = "Builder ID token should be exported as KIRO_AWS_BUILDER_ID_TOKEN" + } + + assert { + condition = !strcontains(local.install_script, "test-token") && !strcontains(local.start_script, "test-token") + error_message = "Builder ID token must not be rendered into scripts" + } +} + +run "scripts_output_includes_start" { + command = apply + + variables { + agent_id = "test-agent" + } + + assert { + condition = output.scripts == ["coder-labs-kiro-crew-install_script", "coder-labs-kiro-crew-start_script"] + error_message = "scripts output should list install and start scripts in run order" + } +} + +run "scripts_output_with_pre_and_post_install" { + command = apply + + variables { + agent_id = "test-agent" + pre_install_script = "echo pre" + post_install_script = "echo post" + } + + assert { + condition = output.scripts == ["coder-labs-kiro-crew-pre_install_script", "coder-labs-kiro-crew-install_script", "coder-labs-kiro-crew-post_install_script", "coder-labs-kiro-crew-start_script"] + error_message = "scripts output should list every configured script in run order" + } +} diff --git a/registry/coder-labs/modules/kiro-crew/main.test.ts b/registry/coder-labs/modules/kiro-crew/main.test.ts new file mode 100644 index 000000000..dfe3dbe25 --- /dev/null +++ b/registry/coder-labs/modules/kiro-crew/main.test.ts @@ -0,0 +1,625 @@ +import { + afterEach, + beforeAll, + describe, + expect, + setDefaultTimeout, + test, +} from "bun:test"; +import { + execContainer, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + TerraformState, +} from "~test"; +import { writeExecutable } from "../../../coder/modules/agentapi/test-util"; +import path from "path"; + +interface ModuleScripts { + pre_install?: string; + install: string; + post_install?: string; + start: string; +} + +const collectScripts = (state: TerraformState): ModuleScripts => { + const byDisplayName: Record = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_script") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + const displayName = attrs.display_name as string | undefined; + const script = attrs.script as string | undefined; + if (displayName && script) { + byDisplayName[displayName] = script; + } + } + } + const install = byDisplayName["Kiro Crew: Install Script"]; + const start = byDisplayName["Kiro Crew: Start Script"]; + if (!install || !start) { + throw new Error("install or start script not found in terraform state"); + } + return { + pre_install: byDisplayName["Kiro Crew: Pre-Install Script"], + install, + post_install: byDisplayName["Kiro Crew: Post-Install Script"], + start, + }; +}; + +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach(async () => { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +const moduleDir = path.resolve(import.meta.dir); +const moduleDataDir = "/home/coder/.coder-modules/coder-labs/kiro-crew"; +const moduleLogDir = `${moduleDataDir}/logs`; +const moduleScriptsDir = `${moduleDataDir}/scripts`; + +// Containers share the host network, so use ports unlikely to be in use. +const gatewayPort = "19899"; +const redirectPort = "19898"; + +interface SetupProps { + // Install mock kiro-cli and kirocrew binaries on PATH. + withMocks?: boolean; + moduleVariables?: Record; +} + +const setup = async ( + props?: SetupProps, +): Promise<{ id: string; scripts: ModuleScripts; state: TerraformState }> => { + const state = await runTerraformApply(moduleDir, { + agent_id: "foo", + port: gatewayPort, + redirect_port: redirectPort, + ...props?.moduleVariables, + }); + const scripts = collectScripts(state); + + const id = await runContainer("codercom/enterprise-node:latest"); + registerCleanup(async () => { + if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") { + console.log(`Not removing container ${id} in debug mode`); + return; + } + await removeContainer(id); + }); + + // `coder exp sync` calls from the coder-utils wrappers need a coder binary. + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/bash\nexit 0\n", + }); + await writeExecutable({ + containerId: id, + filePath: "/tmp/fake-gateway.py", + content: await Bun.file( + path.join(moduleDir, "testdata", "fake-gateway.py"), + ).text(), + }); + if (props?.withMocks) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/kiro-cli", + content: await Bun.file( + path.join(moduleDir, "testdata", "kiro-cli-mock.sh"), + ).text(), + }); + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/kirocrew", + content: await Bun.file( + path.join(moduleDir, "testdata", "kirocrew-mock.sh"), + ).text(), + }); + } + return { id, scripts, state }; +}; + +const envPrefix = (env?: Record) => + Object.entries(env ?? {}) + .map(([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}" && `) + .join(""); + +const runScript = async ( + id: string, + name: string, + script: string, + env?: Record, +) => { + const target = `/tmp/coder-utils-${name}.sh`; + await writeExecutable({ containerId: id, filePath: target, content: script }); + return execContainer(id, ["bash", "-c", `${envPrefix(env)}${target}`]); +}; + +const runOrFail = async ( + id: string, + name: string, + script: string, + env?: Record, +) => { + const resp = await runScript(id, name, script, env); + if (resp.exitCode !== 0) { + console.log(`script ${name} failed:`); + console.log(resp.stdout); + console.log(resp.stderr); + } + expect(resp.exitCode).toBe(0); + return resp; +}; + +// Records curl arguments and copies a per-URL fixture to --output. Exits with +// failExitCode instead when it is non-zero. +const writeCurlMock = async (id: string, failExitCode = 0) => { + await writeExecutable({ + containerId: id, + filePath: "/usr/local/bin/curl", + content: `#!/bin/bash +printf '%s\\n' "$*" >> /tmp/curl-args +out="" +url="" +while [ $# -gt 0 ]; do + case "$1" in + --output) out="$2"; shift ;; + http*) url="$1" ;; + esac + shift +done +if [ ${failExitCode} -ne 0 ]; then + exit ${failExitCode} +fi +case "$url" in + https://cli.kiro.dev/install) cp /tmp/fixtures/kiro-cli-install.sh "$out" ;; + https://download.crew.kiro.dev/cli.sh) cp /tmp/fixtures/kiro-crew-install.sh "$out" ;; + *) exit 22 ;; +esac +`, + }); +}; + +// Fake installers that place binaries where the real installers do: kiro-cli +// in ~/.local/bin and kirocrew in the managed venv. +const writeInstallerFixtures = async (id: string) => { + await execContainer(id, ["mkdir", "-p", "/tmp/fixtures"]); + await writeExecutable({ + containerId: id, + filePath: "/tmp/fixtures/kiro-cli-install.sh", + content: `#!/bin/bash +echo "kiro-cli installer ran" >> /tmp/installers-ran +mkdir -p "$HOME/.local/bin" +printf '#!/bin/sh\\necho "kiro-cli 9.9.9"\\n' > "$HOME/.local/bin/kiro-cli" +chmod +x "$HOME/.local/bin/kiro-cli" +`, + }); + await writeExecutable({ + containerId: id, + filePath: "/tmp/fixtures/kiro-crew-install.sh", + content: `#!/bin/sh +echo "kiro-crew installer ran PYTHONPATH=\${PYTHONPATH-unset}" >> /tmp/installers-ran +mkdir -p "$HOME/.kiro/crew-venv/bin" +cp /tmp/kirocrew-mock.sh "$HOME/.kiro/crew-venv/bin/kirocrew" +chmod +x "$HOME/.kiro/crew-venv/bin/kirocrew" +`, + }); + await writeExecutable({ + containerId: id, + filePath: "/tmp/kirocrew-mock.sh", + content: await Bun.file( + path.join(moduleDir, "testdata", "kirocrew-mock.sh"), + ).text(), + }); +}; + +// Polls a URL from inside the container until curl reports the expected +// status code, then prints " ". A host sets the Host +// header, as Coder's app proxy forwards the browser's Host. +const waitForStatus = async ( + id: string, + url: string, + status: string, + host?: string, +) => { + const hostHeader = host ? `-H 'Host: ${host}'` : ""; + const resp = await execContainer(id, [ + "bash", + "-c", + `for i in $(seq 1 100); do + out=$(/usr/bin/curl -s -o /dev/null ${hostHeader} -w '%{http_code} %{redirect_url}' '${url}') + case "$out" in ${status}*) echo "$out"; exit 0 ;; esac + sleep 0.2 + done + echo "last: $out"; exit 1`, + ]); + if (resp.exitCode !== 0) { + console.log(resp.stdout); + console.log( + await execContainer(id, ["bash", "-c", `cat ${moduleLogDir}/*.log`]), + ); + } + expect(resp.exitCode).toBe(0); + return resp.stdout.trim(); +}; + +setDefaultTimeout(90 * 1000); + +// Names the coder provider resolved for the workspace and owner, which shape +// every app hostname. +const workspaceNames = (state: TerraformState) => { + const nameOf = (type: string) => { + const resource = state.resources.find((r) => r.type === type); + const name = resource?.instances[0]?.attributes.name; + if (typeof name !== "string" || name === "") { + throw new Error(`${type} name not found in terraform state`); + } + return name; + }; + return { + workspace: nameOf("coder_workspace"), + owner: nameOf("coder_workspace_owner"), + }; +}; + +const agentName = "main"; + +// What the agent sets for a deployment whose wildcard is *--apps.coder.example.com. +const proxyURIEnv = (state: TerraformState) => { + const { workspace, owner } = workspaceNames(state); + return { + CODER_WORKSPACE_AGENT_NAME: agentName, + VSCODE_PROXY_URI: `https://{{port}}--${agentName}--${workspace}--${owner}--apps.coder.example.com`, + }; +}; + +const gatewayOrigins = async (id: string) => { + const env = await readFileContainer(id, "/tmp/kirocrew-gateway-env"); + const line = env + .split("\n") + .find((l) => l.startsWith("KIROCREW_CORS_ORIGINS=")); + return (line ?? "").replace("KIROCREW_CORS_ORIGINS=", "").split(","); +}; + +// Requests the redirector with the given Host and returns the redirect +// origin, asserting the gateway trusts it. +const redirectOrigin = async (id: string, host: string) => { + const out = await waitForStatus( + id, + `http://127.0.0.1:${redirectPort}/`, + "302", + host, + ); + const location = out.replace(/^302 /, ""); + expect(location).toEndWith("/?token=test-token"); + const origin = location.replace("/?token=test-token", ""); + expect(await gatewayOrigins(id)).toContain(origin); + return origin; +}; + +describe("kiro-crew", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + test("install-uses-cached-binaries", async () => { + const { id, scripts } = await setup({ withMocks: true }); + await writeCurlMock(id, 22); + await runOrFail(id, "install", scripts.install); + const log = await readFileContainer(id, `${moduleLogDir}/install.log`); + expect(log).toContain("Kiro CLI already installed: kiro-cli 0.0.0-test"); + expect(log).toContain("Kiro Crew already installed: /usr/bin/kirocrew"); + expect(log).toContain("Kiro Crew install completed."); + expect(log).toContain("ARG_USE_CACHED: true"); + expect(log).toContain( + "ARG_KIROCREW_INSTALL_URL: https://download.crew.kiro.dev/cli.sh", + ); + const curl = await execContainer(id, ["test", "-e", "/tmp/curl-args"]); + expect(curl.exitCode).not.toBe(0); + }); + + test("install-runs-installers-when-missing", async () => { + const { id, scripts } = await setup(); + await writeInstallerFixtures(id); + await writeCurlMock(id); + await runOrFail(id, "install", scripts.install, { + PYTHONPATH: "/should/be/unset", + }); + const curlArgs = await readFileContainer(id, "/tmp/curl-args"); + expect(curlArgs).toContain("https://cli.kiro.dev/install"); + expect(curlArgs).toContain("https://download.crew.kiro.dev/cli.sh"); + const ran = await readFileContainer(id, "/tmp/installers-ran"); + expect(ran).toContain("kiro-cli installer ran"); + expect(ran).toContain("kiro-crew installer ran PYTHONPATH=unset"); + const log = await readFileContainer(id, `${moduleLogDir}/install.log`); + expect(log).toContain("Kiro CLI ready."); + expect(log).toContain("Kiro Crew ready."); + }); + + test("install-reinstalls-when-use-cached-is-false", async () => { + const { id, scripts } = await setup({ + withMocks: true, + moduleVariables: { use_cached: "false" }, + }); + await writeInstallerFixtures(id); + await writeCurlMock(id); + await runOrFail(id, "install", scripts.install); + const ran = await readFileContainer(id, "/tmp/installers-ran"); + expect(ran).toContain("kiro-cli installer ran"); + expect(ran).toContain("kiro-crew installer ran"); + }); + + test("install-fails-when-download-fails", async () => { + const { id, scripts } = await setup(); + await writeCurlMock(id, 22); + const resp = await runScript(id, "install", scripts.install); + expect(resp.exitCode).not.toBe(0); + const log = await readFileContainer(id, `${moduleLogDir}/install.log`); + expect(log).toContain("Kiro CLI installation failed."); + }); + + test("install-writes-extra-mcp-json", async () => { + const extra = JSON.stringify({ + mcpServers: { demo: { command: "echo", args: ["it's"] } }, + }); + const { id, scripts } = await setup({ + withMocks: true, + moduleVariables: { extra_mcp_json: extra }, + }); + await runOrFail(id, "install", scripts.install); + const installLog = await readFileContainer( + id, + `${moduleLogDir}/install.log`, + ); + expect(installLog).toContain("ARG_EXTRA_MCP_JSON: (set)"); + expect(installLog).not.toContain("mcpServers"); + const mcp = await readFileContainer( + id, + "/home/coder/.kiro/settings/mcp.json", + ); + expect(JSON.parse(mcp)).toEqual(JSON.parse(extra)); + }); + + test("install-merges-extra-mcp-json", async () => { + const extra = JSON.stringify({ + mcpServers: { demo: { command: "new" } }, + }); + const { id, scripts } = await setup({ + withMocks: true, + moduleVariables: { extra_mcp_json: extra }, + }); + await execContainer(id, [ + "bash", + "-c", + `mkdir -p ~/.kiro/settings && echo '{"mcpServers":{"coder":{"url":"u"},"demo":{"command":"old"}},"other":1}' > ~/.kiro/settings/mcp.json`, + ]); + await runOrFail(id, "install", scripts.install); + const mcp = JSON.parse( + await readFileContainer(id, "/home/coder/.kiro/settings/mcp.json"), + ); + expect(mcp).toEqual({ + mcpServers: { coder: { url: "u" }, demo: { command: "new" } }, + other: 1, + }); + }); + + test("start-launches-gateway-and-redirector", async () => { + const { id, scripts, state } = await setup({ + withMocks: true, + moduleVariables: { allowed_origins: "https://extra.example.com" }, + }); + const { workspace, owner } = workspaceNames(state); + const dashboardOrigin = `https://kirocrew-dashboard--${workspace}--${owner}--apps.coder.example.com`; + await runOrFail(id, "install", scripts.install); + await execContainer(id, [ + "bash", + "-c", + "mkdir -p ~/.kiro/crew && touch ~/.kiro/crew/start-gateway.sh ~/.kiro/crew/redirect-gateway.py", + ]); + const start = await runOrFail(id, "start", scripts.start, { + KIRO_AWS_BUILDER_ID_TOKEN: "builder-token", + ...proxyURIEnv(state), + }); + expect(start.stdout).toContain("Kiro Crew gateway launch dispatched."); + expect(start.stdout).toContain( + `Primary dashboard origin: ${dashboardOrigin}`, + ); + + await waitForStatus(id, `http://127.0.0.1:${redirectPort}/healthz`, "200"); + const health = await execContainer(id, [ + "/usr/bin/curl", + "-s", + `http://127.0.0.1:${gatewayPort}/health`, + ]); + expect(health.stdout).toBe("fake-gateway"); + + const redirect = await waitForStatus( + id, + `http://127.0.0.1:${redirectPort}/`, + "302", + ); + expect(redirect).toBe(`302 ${dashboardOrigin}/?token=test-token`); + + const gatewayEnv = await readFileContainer(id, "/tmp/kirocrew-gateway-env"); + expect(gatewayEnv).toContain(`KIROCREW_PORT=${gatewayPort}`); + expect(gatewayEnv).toContain( + `KIROCREW_CORS_ORIGINS=${dashboardOrigin},https://kirocrew-dashboard--${agentName}--${workspace}--${owner}--apps.coder.example.com,https://extra.example.com`, + ); + expect(gatewayEnv).toContain("KIRO_AWS_BUILDER_ID_TOKEN=builder-token"); + + const calls = await readFileContainer(id, "/tmp/kirocrew-calls"); + expect(calls).toContain(`config set dashboard.url ${dashboardOrigin}`); + + for (const file of [ + `${moduleLogDir}/start.log`, + `${moduleLogDir}/gateway.log`, + `${moduleLogDir}/redirector.log`, + `${moduleScriptsDir}/start-gateway.sh`, + `${moduleScriptsDir}/redirect-gateway.py`, + `${moduleScriptsDir}/coder-origins.py`, + `${moduleDataDir}/coder-origins.json`, + ]) { + const exists = await execContainer(id, ["test", "-f", file]); + expect(exists.exitCode).toBe(0); + } + const legacy = await execContainer(id, [ + "bash", + "-c", + "ls ~/.kiro/crew/start-gateway.sh ~/.kiro/crew/redirect-gateway.py", + ]); + expect(legacy.exitCode).not.toBe(0); + }); + + test("redirector-keeps-browser-on-its-workspace-proxy", async () => { + const { id, scripts, state } = await setup({ + withMocks: true, + moduleVariables: { + workspace_proxy_hostnames: JSON.stringify([ + "*--apps.eu.coder.example.com", + "http://*.apps.lab.example.com:8080", + ]), + }, + }); + const { workspace, owner } = workspaceNames(state); + const sub = `${workspace}--${owner}`; + await runOrFail(id, "install", scripts.install); + const start = await runOrFail( + id, + "start", + scripts.start, + proxyURIEnv(state), + ); + expect(start.stdout).toContain( + "ARG_PROXY_HOSTNAMES: *--apps.eu.coder.example.com,http://*.apps.lab.example.com:8080", + ); + expect(start.stdout).toContain(`ARG_WORKSPACE_NAME: ${workspace}`); + expect(start.stdout).toContain( + `VSCODE_PROXY_URI: ${proxyURIEnv(state).VSCODE_PROXY_URI}`, + ); + + // Primary deployment. + expect( + await redirectOrigin(id, `kirocrew--${sub}--apps.coder.example.com`), + ).toBe(`https://kirocrew-dashboard--${sub}--apps.coder.example.com`); + // Listed proxy, in the form Coder links to and in the form naming the agent. + expect( + await redirectOrigin(id, `kirocrew--${sub}--apps.eu.coder.example.com`), + ).toBe(`https://kirocrew-dashboard--${sub}--apps.eu.coder.example.com`); + expect( + await redirectOrigin( + id, + `KIROCREW--${agentName}--${sub}--apps.eu.coder.example.com`, + ), + ).toBe( + `https://kirocrew-dashboard--${agentName}--${sub}--apps.eu.coder.example.com`, + ); + // Explicit scheme and port. + expect( + await redirectOrigin(id, `kirocrew--${sub}.apps.lab.example.com:8080`), + ).toBe(`http://kirocrew-dashboard--${sub}.apps.lab.example.com:8080`); + // Unlisted proxies and unrelated hosts fall back to the primary origin. + for (const host of [ + `kirocrew--${sub}--apps.ap.coder.example.com`, + `kirocrew--${sub}--apps.attacker.example`, + ]) { + expect(await redirectOrigin(id, host)).toBe( + `https://kirocrew-dashboard--${sub}--apps.coder.example.com`, + ); + } + }); + + test("dashboard-origin-override-is-the-primary-origin", async () => { + const override = "https://crew.apps.example.com"; + const { id, scripts, state } = await setup({ + withMocks: true, + moduleVariables: { dashboard_origin: override }, + }); + const { workspace, owner } = workspaceNames(state); + await runOrFail(id, "install", scripts.install); + await runOrFail(id, "start", scripts.start, proxyURIEnv(state)); + expect( + await redirectOrigin( + id, + `kirocrew--${workspace}--${owner}--apps.coder.example.com`, + ), + ).toBe(override); + expect(await gatewayOrigins(id)).toEqual([override]); + const calls = await readFileContainer(id, "/tmp/kirocrew-calls"); + expect(calls).toContain(`config set dashboard.url ${override}`); + }); + + test("start-falls-back-without-vscode-proxy-uri", async () => { + const { id, scripts, state } = await setup({ withMocks: true }); + const fallback = state.outputs.dashboard_origin.value as string; + await runOrFail(id, "install", scripts.install); + const start = await runOrFail(id, "start", scripts.start); + expect(start.stdout + start.stderr).toContain( + "WARNING: could not determine the wildcard app hostname", + ); + expect(await redirectOrigin(id, "127.0.0.1")).toBe(fallback); + }); + + test("redirector-returns-503-until-gateway-ready", async () => { + const { id, scripts } = await setup({ withMocks: true }); + await runOrFail(id, "install", scripts.install); + await runOrFail(id, "start", scripts.start, { + KIROCREW_MOCK_GATEWAY: "hang", + }); + await waitForStatus(id, `http://127.0.0.1:${redirectPort}/healthz`, "200"); + const resp = await waitForStatus( + id, + `http://127.0.0.1:${redirectPort}/`, + "503", + ); + expect(resp).toStartWith("503"); + }); + + test("start-replaces-stale-listener-on-gateway-port", async () => { + const { id, scripts } = await setup({ withMocks: true }); + await runOrFail(id, "install", scripts.install); + await execContainer(id, [ + "bash", + "-c", + `setsid nohup python3 -m http.server --bind 127.0.0.1 ${gatewayPort} > /dev/null 2>&1 < /dev/null &`, + ]); + await waitForStatus(id, `http://127.0.0.1:${gatewayPort}/`, "200"); + await runOrFail(id, "start", scripts.start); + await waitForStatus(id, `http://127.0.0.1:${gatewayPort}/health`, "200"); + const health = await execContainer(id, [ + "/usr/bin/curl", + "-s", + `http://127.0.0.1:${gatewayPort}/health`, + ]); + expect(health.stdout).toBe("fake-gateway"); + }); + + test("start-fails-without-kirocrew", async () => { + const { id, scripts } = await setup(); + // The install wrapper normally creates the module directories. + await execContainer(id, [ + "mkdir", + "-p", + "/home/coder/.coder-modules/coder-labs/kiro-crew/scripts", + moduleLogDir, + ]); + const resp = await runScript(id, "start", scripts.start); + expect(resp.exitCode).not.toBe(0); + const log = await readFileContainer(id, `${moduleLogDir}/start.log`); + expect(log).toContain("kirocrew binary not found"); + }); +}); diff --git a/registry/coder-labs/modules/kiro-crew/main.tf b/registry/coder-labs/modules/kiro-crew/main.tf new file mode 100644 index 000000000..9013d0473 --- /dev/null +++ b/registry/coder-labs/modules/kiro-crew/main.tf @@ -0,0 +1,288 @@ +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 = "The icon to use for the Kiro Crew apps and scripts." + type = string + default = "/icon/kiro.svg" +} + +variable "port" { + description = "Port the Kiro Crew gateway listens on inside the workspace." + type = number + default = 8899 + + validation { + condition = var.port >= 1 && var.port <= 65535 + error_message = "port must be between 1 and 65535." + } +} + +variable "redirect_port" { + description = "Loopback port for the token-minting redirector that fronts the dashboard so the Kiro Crew app tile opens an authenticated dashboard." + type = number + default = 8898 + + validation { + condition = var.redirect_port >= 1 && var.redirect_port <= 65535 + error_message = "redirect_port must be between 1 and 65535." + } +} + +variable "slug" { + description = "Slug for the visible Kiro Crew app. The hidden dashboard app uses \"-dashboard\"." + type = string + default = "kirocrew" + + validation { + condition = can(regex("^[a-z0-9]+(-[a-z0-9]+)*$", var.slug)) + error_message = "slug must contain only lowercase letters, numbers, and single hyphens between them." + } +} + +variable "order" { + description = "The order determines the position of the app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." + type = number + default = null +} + +variable "group" { + description = "The name of a group that this app belongs to." + type = string + default = null +} + +variable "share" { + description = "The sharing level for the Kiro Crew apps: 'owner', 'authenticated', or 'public'." + type = string + default = "owner" + + validation { + condition = contains(["owner", "authenticated", "public"], var.share) + error_message = "share must be 'owner', 'authenticated', or 'public'." + } +} + +variable "open_in" { + description = "Where the Kiro Crew app opens: 'tab' or 'slim-window'." + type = string + default = "slim-window" + + validation { + condition = contains(["tab", "slim-window"], var.open_in) + error_message = "open_in must be 'tab' or 'slim-window'." + } +} + +variable "use_cached" { + description = "Skip running the Kiro CLI and Kiro Crew installers when the binaries are already present. Combined with binaries baked into the workspace image, this lets the module run without contacting any installer endpoint." + type = bool + default = true +} + +variable "kiro_cli_install_url" { + description = "URL the Kiro CLI installer script is downloaded from. Point this at an internal mirror or artifact store in restricted or air-gapped environments." + type = string + default = "https://cli.kiro.dev/install" +} + +variable "kirocrew_install_url" { + description = "URL the Kiro Crew installer script is downloaded from. Point this at an internal mirror or artifact store in restricted or air-gapped environments." + type = string + default = "https://download.crew.kiro.dev/cli.sh" +} + +variable "extra_mcp_json" { + description = "Optional MCP configuration JSON to deep-merge into ~/.kiro/settings/mcp.json. Keys from this value replace existing keys with the same name; other existing keys are kept." + type = string + default = "" + + validation { + condition = var.extra_mcp_json == "" || can(jsondecode(var.extra_mcp_json)) + error_message = "extra_mcp_json must be empty or valid JSON." + } +} + +variable "aws_builder_id_token" { + description = "Optional AWS Builder ID access token, for example from a coder_external_auth data source. Exported to the workspace as KIRO_AWS_BUILDER_ID_TOKEN." + type = string + default = "" + sensitive = true +} + +variable "allowed_origins" { + description = "Comma-separated extra origins that the Kiro Crew gateway trusts in its Host/Origin allowlist, for example the Coder access URL origin. The dashboard app origin is always included. Passed to the gateway as KIROCREW_CORS_ORIGINS." + type = string + default = "" +} + +variable "dashboard_origin" { + description = "Override for the external origin of the hidden dashboard app. When empty, the module derives it at startup from the agent's VSCODE_PROXY_URI, which follows the deployment's wildcard access URL." + type = string + default = "" + + validation { + condition = var.dashboard_origin == "" || can(regex("^https?://[^/]+$", var.dashboard_origin)) + error_message = "dashboard_origin must be empty or an origin such as 'https://app.example.com' with no path." + } +} + +variable "workspace_proxy_hostnames" { + description = "Wildcard app hostnames of the deployment's workspace proxies, for example [\"*.apps.eu.example.com\"]. Use the wildcard_hostname that /api/v2/regions reports for each proxy. A scheme prefix is optional and defaults to the access URL scheme. A browser that opens Kiro Crew through a listed proxy stays on that proxy. A browser that opens it through an unlisted proxy is sent to the primary dashboard origin." + type = list(string) + default = [] + + validation { + condition = alltrue([ + for h in var.workspace_proxy_hostnames : + can(regex("^(https?://)?[A-Za-z0-9-]*\\*[A-Za-z0-9-]*(\\.[A-Za-z0-9-]+)+(:[0-9]+)?$", h)) + ]) + error_message = "Each workspace_proxy_hostnames entry must be a wildcard hostname such as '*.apps.example.com' or '*--apps.example.com', with exactly one '*' in the first label and no path." + } +} + +variable "pre_install_script" { + description = "Custom script to run before installing Kiro CLI and Kiro Crew, for example to install system packages." + type = string + default = null +} + +variable "post_install_script" { + description = "Custom script to run after installing Kiro CLI and Kiro Crew, before the gateway starts." + type = string + default = null +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +locals { + module_dir_name = ".coder-modules/coder-labs/kiro-crew" + module_directory = "$HOME/${local.module_dir_name}" + + dashboard_slug = "${var.slug}-dashboard" + access_scheme = startswith(data.coder_workspace.me.access_url, "http://") ? "http" : "https" + access_host = trimsuffix(replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", ""), "/") + + # The start script derives the real dashboard origins from the agent's + # VSCODE_PROXY_URI and var.workspace_proxy_hostnames, because the wildcard + # app hostname is not available to Terraform. This origin, which assumes + # "*.", is only the last resort when neither is available. + fallback_dashboard_origin = "${local.access_scheme}://${local.dashboard_slug}--${data.coder_workspace.me.name}--${data.coder_workspace_owner.me.name}.${local.access_host}" + + dashboard_origin = var.dashboard_origin != "" ? var.dashboard_origin : local.fallback_dashboard_origin + + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_USE_CACHED = tostring(var.use_cached) + ARG_EXTRA_MCP_JSON = var.extra_mcp_json != "" ? base64encode(var.extra_mcp_json) : "" + ARG_KIRO_CLI_INSTALL_URL = var.kiro_cli_install_url + ARG_KIROCREW_INSTALL_URL = var.kirocrew_install_url + }) + + start_script = templatefile("${path.module}/scripts/start.sh.tftpl", { + ARG_MODULE_DIRECTORY = local.module_directory + ARG_PORT = tostring(var.port) + ARG_REDIRECT_PORT = tostring(var.redirect_port) + ARG_APP_SLUG = var.slug + ARG_DASHBOARD_SLUG = local.dashboard_slug + ARG_WORKSPACE_NAME = data.coder_workspace.me.name + ARG_OWNER_NAME = data.coder_workspace_owner.me.name + ARG_ACCESS_SCHEME = local.access_scheme + ARG_DASHBOARD_ORIGIN = base64encode(var.dashboard_origin) + ARG_FALLBACK_ORIGIN = base64encode(local.fallback_dashboard_origin) + ARG_PROXY_HOSTNAMES = base64encode(join("\n", var.workspace_proxy_hostnames)) + ARG_EXTRA_ALLOWED_ORIGINS = base64encode(trimspace(var.allowed_origins)) + }) +} + +# The token is exported through the agent environment so it is never rendered +# into a script. +resource "coder_env" "aws_builder_id_token" { + count = var.aws_builder_id_token != "" ? 1 : 0 + agent_id = var.agent_id + name = "KIRO_AWS_BUILDER_ID_TOKEN" + value = var.aws_builder_id_token +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.2" + + agent_id = var.agent_id + module_directory = local.module_directory + display_name_prefix = "Kiro Crew" + icon = var.icon + pre_install_script = var.pre_install_script + install_script = local.install_script + post_install_script = var.post_install_script + start_script = local.start_script +} + +# The visible tile points at a loopback redirector that mints a short-lived +# gateway token and redirects to the hidden dashboard app with ?token=. Kiro +# Crew requires a token for every browser request, and a static coder_app URL +# cannot embed a token that only exists at runtime. +resource "coder_app" "kiro_crew" { + agent_id = var.agent_id + slug = var.slug + display_name = "Kiro Crew" + url = "http://localhost:${var.redirect_port}/" + icon = var.icon + subdomain = true + share = var.share + order = var.order + group = var.group + open_in = var.open_in + + healthcheck { + url = "http://localhost:${var.redirect_port}/healthz" + interval = 5 + threshold = 6 + } +} + +# Serves the dashboard SPA and its websocket through Coder's subdomain proxy. +resource "coder_app" "kiro_crew_dashboard" { + agent_id = var.agent_id + slug = local.dashboard_slug + url = "http://localhost:${var.port}/" + subdomain = true + share = var.share + hidden = true + open_in = var.open_in + + healthcheck { + url = "http://localhost:${var.port}/health" + interval = 5 + threshold = 6 + } +} + +output "kirocrew_url" { + description = "Local URL of the Kiro Crew gateway inside the workspace." + value = "http://localhost:${var.port}/" +} + +output "dashboard_origin" { + description = "The dashboard_origin override, or the origin assumed when the wildcard app hostname cannot be determined at startup. The start script logs the origins it actually uses." + value = local.dashboard_origin +} + +output "scripts" { + description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order (pre_install, install, post_install, start). Scripts that were not configured are absent from the list." + value = module.coder_utils.scripts +} diff --git a/registry/coder-labs/modules/kiro-crew/scripts/install.sh.tftpl b/registry/coder-labs/modules/kiro-crew/scripts/install.sh.tftpl new file mode 100644 index 000000000..f908a7d5a --- /dev/null +++ b/registry/coder-labs/modules/kiro-crew/scripts/install.sh.tftpl @@ -0,0 +1,184 @@ +#!/usr/bin/env bash + +set -euo pipefail + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_USE_CACHED='${ARG_USE_CACHED}' +ARG_EXTRA_MCP_JSON=$(echo -n '${ARG_EXTRA_MCP_JSON}' | base64 -d) +ARG_KIRO_CLI_INSTALL_URL='${ARG_KIRO_CLI_INSTALL_URL}' +ARG_KIROCREW_INSTALL_URL='${ARG_KIROCREW_INSTALL_URL}' + +KIRO_CREW_VENV_BIN="$HOME/.kiro/crew-venv/bin/kirocrew" + +mkdir -p "$HOME/.local/bin" +export PATH="$HOME/.local/bin:$HOME/bin:$PATH" + +echo "--------------------------------" +printf "ARG_USE_CACHED: %s\n" "$${ARG_USE_CACHED}" +printf "ARG_EXTRA_MCP_JSON: %s\n" "$${ARG_EXTRA_MCP_JSON:+(set)}" +printf "ARG_KIRO_CLI_INSTALL_URL: %s\n" "$${ARG_KIRO_CLI_INSTALL_URL}" +printf "ARG_KIROCREW_INSTALL_URL: %s\n" "$${ARG_KIROCREW_INSTALL_URL}" +echo "--------------------------------" + +# Downloads an installer to a temporary file so a failed or partial download +# is never executed. Runs in a subshell to scope the cleanup trap. +run_installer() ( + local url="$1" + local interpreter="$2" + local installer_file + installer_file=$(mktemp) + trap 'rm -f "$${installer_file}"' EXIT + + if ! curl --fail --silent --show-error --location \ + --retry 2 --retry-delay 1 --retry-all-errors \ + --connect-timeout 10 --max-time 300 \ + --output "$${installer_file}" "$${url}"; then + echo "ERROR: Installer could not be downloaded from $${url}." >&2 + return 1 + fi + + if ! "$${interpreter}" -n "$${installer_file}"; then + echo "ERROR: Installer downloaded from $${url} is not a valid script." >&2 + return 1 + fi + + "$${interpreter}" "$${installer_file}" < /dev/null +) + +link_into_script_bin_dir() { + local name="$1" + local target="$2" + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/$${name}" ]; then + ln -s "$${target}" "$${CODER_SCRIPT_BIN_DIR}/$${name}" + echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/$${name} -> $${target}" + fi +} + +install_kiro_cli() { + if [ "$${ARG_USE_CACHED}" = "true" ] && command_exists kiro-cli; then + echo "Kiro CLI already installed: $(kiro-cli --version 2> /dev/null || echo 'unknown version')" + else + echo "Installing Kiro CLI..." + # The installer prompts on /dev/tty when kiro-cli already exists in + # ~/.local/bin, so remove the old binaries first as it would on confirm. + rm -f "$HOME/.local/bin/kiro-cli" "$HOME/.local/bin/kiro-cli-chat" + if ! run_installer "$${ARG_KIRO_CLI_INSTALL_URL}" bash; then + echo "ERROR: Kiro CLI installation failed." >&2 + exit 1 + fi + hash -r + fi + + if ! command_exists kiro-cli; then + echo "ERROR: kiro-cli was not found on PATH after installation." >&2 + exit 1 + fi + link_into_script_bin_dir kiro-cli "$(command -v kiro-cli)" + echo "Kiro CLI ready." +} + +# The Kiro Crew installer provisions its own managed Python via uv (a managed +# CPython under ~/.kiro/crew-python, and depending on the installer version a +# managed venv at ~/.kiro/crew-venv or a pipx venv under ~/.local/share/pipx), +# so the module needs neither system Python nor a system pipx. +# resolve_kiro_python returns an interpreter for the module's own helper +# scripts, preferring the installer-provided Python and falling back to any +# system python3 only if none is found. +resolve_kiro_python() { + local candidate + for candidate in \ + "$HOME/.kiro/crew-venv/bin/python" \ + "$HOME"/.kiro/crew-python/*/bin/python3 \ + "$HOME/.local/share/pipx/venvs/kirocrew/bin/python"; do + if [ -x "$${candidate}" ]; then + echo "$${candidate}" + return 0 + fi + done + if command_exists python3; then + command -v python3 + return 0 + fi + return 1 +} + +install_kiro_crew() { + if [ "$${ARG_USE_CACHED}" = "true" ] && command_exists kirocrew; then + echo "Kiro Crew already installed: $(command -v kirocrew)" + elif [ "$${ARG_USE_CACHED}" = "true" ] && [ -x "$${KIRO_CREW_VENV_BIN}" ]; then + echo "Kiro Crew already installed: $${KIRO_CREW_VENV_BIN}" + else + echo "Installing Kiro Crew..." + # An inherited PYTHONPATH or PYTHONHOME makes pip treat foreign packages + # as already satisfied, which leaves the managed venv without its + # dependencies (ImportError on the first gateway start). + unset PYTHONPATH PYTHONHOME + if ! run_installer "$${ARG_KIROCREW_INSTALL_URL}" sh; then + echo "ERROR: Kiro Crew installation failed." >&2 + exit 1 + fi + hash -r + fi + + local kirocrew_bin="" + if command_exists kirocrew; then + kirocrew_bin="$(command -v kirocrew)" + elif [ -x "$${KIRO_CREW_VENV_BIN}" ]; then + kirocrew_bin="$${KIRO_CREW_VENV_BIN}" + else + echo "ERROR: kirocrew was not found after installation." >&2 + exit 1 + fi + link_into_script_bin_dir kirocrew "$${kirocrew_bin}" + echo "Kiro Crew ready." +} + +apply_extra_mcp() { + if [ -z "$${ARG_EXTRA_MCP_JSON}" ]; then + return + fi + + local mcp_file="$HOME/.kiro/settings/mcp.json" + echo "Applying extra MCP configuration to $${mcp_file}..." + mkdir -p "$(dirname "$${mcp_file}")" + + local python_bin + python_bin="$(resolve_kiro_python || true)" + + if [ -f "$${mcp_file}" ] && [ -n "$${python_bin}" ]; then + "$${python_bin}" - "$${mcp_file}" "$${ARG_EXTRA_MCP_JSON}" << 'PYEOF' +import json +import sys + +with open(sys.argv[1]) as f: + existing = json.load(f) +new = json.loads(sys.argv[2]) + + +def merge(a, b): + for k, v in b.items(): + if k in a and isinstance(a[k], dict) and isinstance(v, dict): + merge(a[k], v) + else: + a[k] = v + + +merge(existing, new) +with open(sys.argv[1], "w") as f: + f.write(json.dumps(existing, indent=2)) +PYEOF + echo "MCP configuration merged." + else + printf '%s' "$${ARG_EXTRA_MCP_JSON}" > "$${mcp_file}" + echo "MCP configuration written." + fi +} + +install_kiro_cli +install_kiro_crew +apply_extra_mcp + +echo "Kiro Crew install completed." diff --git a/registry/coder-labs/modules/kiro-crew/scripts/start.sh.tftpl b/registry/coder-labs/modules/kiro-crew/scripts/start.sh.tftpl new file mode 100644 index 000000000..2db96e960 --- /dev/null +++ b/registry/coder-labs/modules/kiro-crew/scripts/start.sh.tftpl @@ -0,0 +1,361 @@ +#!/usr/bin/env bash + +set -euo pipefail + +# module_directory is a constant set in main.tf, so double-quoting it to +# expand its literal "$HOME" at runtime is safe. +ARG_MODULE_DIRECTORY="${ARG_MODULE_DIRECTORY}" +ARG_PORT='${ARG_PORT}' +ARG_REDIRECT_PORT='${ARG_REDIRECT_PORT}' +ARG_APP_SLUG='${ARG_APP_SLUG}' +ARG_DASHBOARD_SLUG='${ARG_DASHBOARD_SLUG}' +ARG_WORKSPACE_NAME='${ARG_WORKSPACE_NAME}' +ARG_OWNER_NAME='${ARG_OWNER_NAME}' +ARG_ACCESS_SCHEME='${ARG_ACCESS_SCHEME}' +ARG_DASHBOARD_ORIGIN=$(echo -n '${ARG_DASHBOARD_ORIGIN}' | base64 -d) +ARG_FALLBACK_ORIGIN=$(echo -n '${ARG_FALLBACK_ORIGIN}' | base64 -d) +ARG_PROXY_HOSTNAMES=$(echo -n '${ARG_PROXY_HOSTNAMES}' | base64 -d) +ARG_EXTRA_ALLOWED_ORIGINS=$(echo -n '${ARG_EXTRA_ALLOWED_ORIGINS}' | base64 -d) + +echo "--------------------------------" +printf "ARG_MODULE_DIRECTORY: %s\n" "$${ARG_MODULE_DIRECTORY}" +printf "ARG_PORT: %s\n" "$${ARG_PORT}" +printf "ARG_REDIRECT_PORT: %s\n" "$${ARG_REDIRECT_PORT}" +printf "ARG_APP_SLUG: %s\n" "$${ARG_APP_SLUG}" +printf "ARG_DASHBOARD_SLUG: %s\n" "$${ARG_DASHBOARD_SLUG}" +printf "ARG_WORKSPACE_NAME: %s\n" "$${ARG_WORKSPACE_NAME}" +printf "ARG_OWNER_NAME: %s\n" "$${ARG_OWNER_NAME}" +printf "ARG_ACCESS_SCHEME: %s\n" "$${ARG_ACCESS_SCHEME}" +printf "ARG_DASHBOARD_ORIGIN: %s\n" "$${ARG_DASHBOARD_ORIGIN}" +printf "ARG_FALLBACK_ORIGIN: %s\n" "$${ARG_FALLBACK_ORIGIN}" +printf "ARG_PROXY_HOSTNAMES: %s\n" "$${ARG_PROXY_HOSTNAMES//$'\n'/,}" +printf "ARG_EXTRA_ALLOWED_ORIGINS: %s\n" "$${ARG_EXTRA_ALLOWED_ORIGINS}" +# Runtime inputs from the agent that the dashboard origins are derived from. +printf "CODER_WORKSPACE_AGENT_NAME: %s\n" "$${CODER_WORKSPACE_AGENT_NAME:-}" +printf "VSCODE_PROXY_URI: %s\n" "$${VSCODE_PROXY_URI:-}" +echo "--------------------------------" + +LOG_DIR="$${ARG_MODULE_DIRECTORY}/logs" +GATEWAY_LOG="$${LOG_DIR}/gateway.log" +REDIRECTOR_LOG="$${LOG_DIR}/redirector.log" +SCRIPTS_DIR="$${ARG_MODULE_DIRECTORY}/scripts" +LAUNCHER_PATH="$${SCRIPTS_DIR}/start-gateway.sh" +REDIRECTOR_PATH="$${SCRIPTS_DIR}/redirect-gateway.py" +ORIGINS_HELPER_PATH="$${SCRIPTS_DIR}/coder-origins.py" +ORIGINS_PATH="$${ARG_MODULE_DIRECTORY}/coder-origins.json" + +mkdir -p "$${LOG_DIR}" "$${SCRIPTS_DIR}" +# Earlier versions wrote these into Kiro Crew's own data directory. +rm -f "$HOME/.kiro/crew/start-gateway.sh" "$HOME/.kiro/crew/redirect-gateway.py" +export PATH="$HOME/.local/bin:$HOME/bin:$PATH" + +# Everything below is best effort and must reach the launch step, so disable +# errexit for the remainder of the script. +set +e + +# Stops whatever listens on a port. Never match by process name: the agent +# runs scripts with their full text in argv, so a pattern such as "kirocrew" +# would also match, and terminate, this script. +free_port() { + local port="$1" + if command -v fuser > /dev/null 2>&1; then + fuser -k "$${port}/tcp" > /dev/null 2>&1 || true + else + local port_pid + port_pid="$(ss -ltnHp "sport = :$${port}" 2> /dev/null | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)" + if [ -n "$${port_pid}" ] && [ "$${port_pid}" != "$$" ] && [ "$${port_pid}" != "$PPID" ]; then + kill "$${port_pid}" > /dev/null 2>&1 || true + fi + fi +} + +KIROCREW_CMD="" +if command -v kirocrew > /dev/null 2>&1; then + KIROCREW_CMD="$(command -v kirocrew)" +elif [ -x "$HOME/.kiro/crew-venv/bin/kirocrew" ]; then + KIROCREW_CMD="$HOME/.kiro/crew-venv/bin/kirocrew" +else + echo "ERROR: kirocrew binary not found. Check the install log in $${LOG_DIR}/install.log." >&2 + exit 1 +fi + +free_port "$${ARG_PORT}" +free_port "$${ARG_REDIRECT_PORT}" + +echo "Using kirocrew binary: $${KIROCREW_CMD}" +echo "Gateway port: $${ARG_PORT}" +echo "Gateway log: $${GATEWAY_LOG}" + +# Run the loopback redirector with the Python the Kiro Crew installer +# provisions (a managed CPython under ~/.kiro/crew-python, or a managed/pipx +# venv depending on the installer version), falling back to system python3 +# only if none is found, so the module does not depend on a system Python. +PYTHON_BIN="python3" +for candidate in \ + "$HOME/.kiro/crew-venv/bin/python" \ + "$HOME"/.kiro/crew-python/*/bin/python3 \ + "$HOME/.local/share/pipx/venvs/kirocrew/bin/python"; do + if [ -x "$${candidate}" ]; then + PYTHON_BIN="$${candidate}" + break + fi +done +echo "Redirector Python: $${PYTHON_BIN}" + +# Coder serves subdomain apps at with "*" replaced by +# ----, on the primary deployment and on every +# workspace proxy, each with its own wildcard hostname. The gateway only +# accepts Host/Origin values from an exact allowlist that is fixed at launch, +# so this helper expands every known wildcard into exact origins once and +# writes one snapshot. The gateway allowlist and the redirector routes both +# come from that snapshot, so the redirector never sends a token to an origin +# the gateway rejects or to a host taken from the request. +cat > "$${ORIGINS_HELPER_PATH}" << 'PYEOF' +import json +import os +import sys +from urllib.parse import urlsplit + +env = os.environ +app_slug = env["KC_APP_SLUG"] +dash_slug = env["KC_DASHBOARD_SLUG"] +workspace = env["KC_WORKSPACE_NAME"] +owner = env["KC_OWNER_NAME"] +agent = env.get("CODER_WORKSPACE_AGENT_NAME", "") +access_scheme = env.get("KC_ACCESS_SCHEME", "https") +override = env.get("KC_DASHBOARD_ORIGIN", "").strip().rstrip("/") +fallback = env.get("KC_FALLBACK_ORIGIN", "").strip().rstrip("/") +proxy_uri = env.get("VSCODE_PROXY_URI", "").strip() + + +def log(msg): + print(msg, file=sys.stderr) + + +def split_wildcard(value, default_scheme): + """Returns (scheme, host-pattern) for a wildcard such as '*.apps.example.com'.""" + value = value.strip().rstrip("/") + scheme = default_scheme + for prefix in ("https://", "http://"): + if value.lower().startswith(prefix): + scheme = prefix[:-3] + value = value[len(prefix):] + first_label = value.split(".", 1)[0] + if not value or "/" in value or value.count("*") != 1 or "*" not in first_label: + return None + return scheme, value.lower() + + +def primary_wildcard(): + """Recovers the primary wildcard hostname from VSCODE_PROXY_URI. + + Coder sets it to :// with "*" replaced by + "{{port}}------". + """ + if not proxy_uri: + return None + marker = "{{port}}--%s--%s--%s" % (agent, workspace, owner) + parts = urlsplit(proxy_uri) + host = parts.netloc + idx = host.lower().find(marker.lower()) if agent else -1 + if parts.scheme not in ("http", "https") or idx < 0: + log("WARNING: VSCODE_PROXY_URI has an unexpected format: %s" % proxy_uri) + return None + return split_wildcard( + "%s://%s*%s" % (parts.scheme, host[:idx], host[idx + len(marker):]), + parts.scheme, + ) + + +def expand(wildcard, subdomain): + scheme, pattern = wildcard + return scheme, pattern.replace("*", subdomain.lower(), 1) + + +wildcards = [] +primary_from_proxy_uri = None +if not override: + primary_from_proxy_uri = primary_wildcard() + if primary_from_proxy_uri: + wildcards.append(primary_from_proxy_uri) +for line in env.get("KC_PROXY_HOSTNAMES", "").splitlines(): + if not line.strip(): + continue + parsed = split_wildcard(line, access_scheme) + if parsed is None: + log("WARNING: ignoring invalid workspace proxy hostname: %s" % line) + elif parsed not in wildcards: + wildcards.append(parsed) + +# Coder omits the agent from slug app subdomains, but also routes the form +# that names the agent, so both are trusted. +suffixes = ["--%s--%s" % (workspace, owner)] +if agent: + suffixes.append("--%s--%s--%s" % (agent, workspace, owner)) + +routes = {} +for wildcard in wildcards: + for suffix in suffixes: + _, app_host = expand(wildcard, app_slug + suffix) + scheme, dash_host = expand(wildcard, dash_slug + suffix) + routes[app_host] = "%s://%s" % (scheme, dash_host) + +if override: + primary = override +elif wildcards: + scheme, dash_host = expand(wildcards[0], dash_slug + suffixes[0]) + primary = "%s://%s" % (scheme, dash_host) + if primary_from_proxy_uri is None: + log("WARNING: VSCODE_PROXY_URI is not set; using the first workspace proxy as the primary dashboard origin.") +else: + primary = fallback + log("WARNING: could not determine the wildcard app hostname; assuming %s. Set dashboard_origin if the dashboard is unreachable." % fallback) + +allowed = [primary] +for origin in list(routes.values()) + env.get("KC_EXTRA_ALLOWED_ORIGINS", "").split(","): + origin = origin.strip().rstrip("/") + if origin and origin not in allowed: + allowed.append(origin) + +with open(sys.argv[1], "w") as f: + json.dump({"primary": primary, "routes": routes, "allowed_origins": allowed}, f, indent=2) +print(primary) +print(",".join(allowed)) +PYEOF + +DASHBOARD_URL="" +ALLOWED_ORIGINS="" +{ + read -r DASHBOARD_URL + read -r ALLOWED_ORIGINS +} < <( + KC_APP_SLUG="$${ARG_APP_SLUG}" \ + KC_DASHBOARD_SLUG="$${ARG_DASHBOARD_SLUG}" \ + KC_WORKSPACE_NAME="$${ARG_WORKSPACE_NAME}" \ + KC_OWNER_NAME="$${ARG_OWNER_NAME}" \ + KC_ACCESS_SCHEME="$${ARG_ACCESS_SCHEME}" \ + KC_DASHBOARD_ORIGIN="$${ARG_DASHBOARD_ORIGIN}" \ + KC_FALLBACK_ORIGIN="$${ARG_FALLBACK_ORIGIN}" \ + KC_PROXY_HOSTNAMES="$${ARG_PROXY_HOSTNAMES}" \ + KC_EXTRA_ALLOWED_ORIGINS="$${ARG_EXTRA_ALLOWED_ORIGINS}" \ + "$${PYTHON_BIN}" "$${ORIGINS_HELPER_PATH}" "$${ORIGINS_PATH}" +) +if [ -z "$${DASHBOARD_URL}" ]; then + echo "ERROR: could not compute the Kiro Crew dashboard origins." >&2 + exit 1 +fi +echo "Primary dashboard origin: $${DASHBOARD_URL}" +echo "Gateway allowed origins: $${ALLOWED_ORIGINS}" + +if [ -n "$${KIRO_API_KEY:-}" ]; then + echo "Headless Kiro auth: KIRO_API_KEY detected; kiro-cli authenticates non-interactively." +else + echo "Headless Kiro auth: KIRO_API_KEY not set; sign in with 'kiro-cli login', or create a Coder user secret that sets KIRO_API_KEY." +fi + +# The launcher is written with a quoted heredoc so nothing expands at write +# time. Values reach it through the environment at launch. +# +# Kiro Crew sandboxes agent subprocesses with a Linux user namespace. Many +# Kubernetes pods set user.max_user_namespaces=0, so when unshare fails the +# launcher allows unsandboxed exec and the pod is the isolation boundary. +cat > "$${LAUNCHER_PATH}" << 'LAUNCHER' +#!/usr/bin/env bash +# Trust the Coder proxy origins in the gateway's Host/Origin allowlist. +[ -n "$KC_ORIGINS" ] && export KIROCREW_CORS_ORIGINS="$KC_ORIGINS" +# Scope token cookies to the dashboard app's public origin. +[ -n "$KC_DASHBOARD_URL" ] && "$KC_CMD" config set dashboard.url "$KC_DASHBOARD_URL" > /dev/null 2>&1 || true +if ! unshare --user --map-root-user true > /dev/null 2>&1; then + "$KC_CMD" config set agent.sandbox_allow_unsandboxed_exec true > /dev/null 2>&1 || true +fi +exec env KIROCREW_PORT="$KC_PORT" "$KC_CMD" gateway +LAUNCHER +chmod +x "$${LAUNCHER_PATH}" + +# The gateway can take a while to cold start, and the agent terminates start +# scripts that run too long, so the gateway runs in a detached session and +# readiness is reported by the dashboard app healthcheck. +: > "$${GATEWAY_LOG}" 2> /dev/null || true +setsid nohup env \ + KC_CMD="$${KIROCREW_CMD}" \ + KC_PORT="$${ARG_PORT}" \ + KC_ORIGINS="$${ALLOWED_ORIGINS}" \ + KC_DASHBOARD_URL="$${DASHBOARD_URL}" \ + bash "$${LAUNCHER_PATH}" >> "$${GATEWAY_LOG}" 2>&1 < /dev/null & +disown 2> /dev/null || true +echo "Kiro Crew gateway launch dispatched." + +# The visible app tile points at this loopback redirector. Each request mints +# a short-lived token from the gateway's loopback secret and redirects to the +# dashboard app with ?token=. Coder forwards the browser's Host, which selects +# the dashboard origin on the same primary or proxy hostname. Unknown hosts go +# to the primary origin. /healthz answers without minting a token so the app +# healthcheck does not create tokens. +cat > "$${REDIRECTOR_PATH}" << 'PYEOF' +import http.server +import json +import os +import urllib.request + +REDIR_PORT = int(os.environ["REDIR_PORT"]) +GW_BASE = "http://127.0.0.1:" + os.environ["GW_PORT"] +with open(os.environ["ORIGINS_PATH"]) as f: + ORIGINS = json.load(f) +PRIMARY = ORIGINS["primary"].rstrip("/") +ROUTES = ORIGINS["routes"] +SECRET = os.path.expanduser("~/.kiro/crew/.local_secret") + + +class Handler(http.server.BaseHTTPRequestHandler): + def _mint(self): + with open(SECRET) as f: + secret = f.read().strip() + req = urllib.request.Request( + GW_BASE + "/api/token/local?ttl=24h", + headers={"X-Local-Secret": secret}, + ) + with urllib.request.urlopen(req, timeout=5) as resp: + return json.load(resp).get("token", "") + + def _dashboard_origin(self): + host = (self.headers.get("Host") or "").strip().lower().rstrip(".") + return ROUTES.get(host, PRIMARY).rstrip("/") + + def do_GET(self): + if self.path.startswith("/healthz"): + self.send_response(200) + self.end_headers() + self.wfile.write(b"ok") + return + try: + token = self._mint() + except Exception: + token = "" + if not token: + self.send_response(503) + self.end_headers() + self.wfile.write(b"Kiro Crew gateway still starting - retry shortly") + return + self.send_response(302) + self.send_header("Location", self._dashboard_origin() + "/?token=" + token) + self.send_header("Cache-Control", "no-store") + self.send_header("Referrer-Policy", "no-referrer") + self.end_headers() + + def log_message(self, *args): + pass + + +http.server.HTTPServer(("127.0.0.1", REDIR_PORT), Handler).serve_forever() +PYEOF + +: > "$${REDIRECTOR_LOG}" 2> /dev/null || true +setsid nohup env \ + REDIR_PORT="$${ARG_REDIRECT_PORT}" \ + GW_PORT="$${ARG_PORT}" \ + ORIGINS_PATH="$${ORIGINS_PATH}" \ + "$${PYTHON_BIN}" "$${REDIRECTOR_PATH}" >> "$${REDIRECTOR_LOG}" 2>&1 < /dev/null & +disown 2> /dev/null || true +echo "Kiro Crew redirector dispatched on port $${ARG_REDIRECT_PORT} -> $${DASHBOARD_URL}" + +exit 0 diff --git a/registry/coder-labs/modules/kiro-crew/testdata/fake-gateway.py b/registry/coder-labs/modules/kiro-crew/testdata/fake-gateway.py new file mode 100644 index 000000000..c4f81e244 --- /dev/null +++ b/registry/coder-labs/modules/kiro-crew/testdata/fake-gateway.py @@ -0,0 +1,36 @@ +import http.server +import json +import os + +PORT = int(os.environ["KIROCREW_PORT"]) +SECRET_PATH = os.path.expanduser("~/.kiro/crew/.local_secret") + + +class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + self.send_response(200) + self.end_headers() + self.wfile.write(b"fake-gateway") + return + if self.path.startswith("/api/token/local"): + with open(SECRET_PATH) as f: + secret = f.read().strip() + if self.headers.get("X-Local-Secret") != secret: + self.send_response(401) + self.end_headers() + return + body = json.dumps({"token": "test-token"}).encode() + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(body) + return + self.send_response(404) + self.end_headers() + + def log_message(self, *args): + pass + + +http.server.HTTPServer(("127.0.0.1", PORT), Handler).serve_forever() diff --git a/registry/coder-labs/modules/kiro-crew/testdata/kiro-cli-mock.sh b/registry/coder-labs/modules/kiro-crew/testdata/kiro-cli-mock.sh new file mode 100644 index 000000000..af2c62899 --- /dev/null +++ b/registry/coder-labs/modules/kiro-crew/testdata/kiro-cli-mock.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +if [[ "$1" == "--version" ]]; then + echo "kiro-cli 0.0.0-test" + exit 0 +fi + +echo "kiro-cli invoked with: $*" +exit 0 diff --git a/registry/coder-labs/modules/kiro-crew/testdata/kirocrew-mock.sh b/registry/coder-labs/modules/kiro-crew/testdata/kirocrew-mock.sh new file mode 100644 index 000000000..b2eb11639 --- /dev/null +++ b/registry/coder-labs/modules/kiro-crew/testdata/kirocrew-mock.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash + +# Stand-in for the kirocrew CLI. Records every invocation and, for +# `kirocrew gateway`, records the environment it was launched with and starts +# a fake gateway that serves /health and /api/token/local. + +echo "$*" >> /tmp/kirocrew-calls + +case "$1" in + --version) + echo "kirocrew 0.0.0-test" + ;; + gateway) + { + echo "KIROCREW_PORT=$KIROCREW_PORT" + echo "KIROCREW_CORS_ORIGINS=${KIROCREW_CORS_ORIGINS:-}" + echo "KIRO_AWS_BUILDER_ID_TOKEN=${KIRO_AWS_BUILDER_ID_TOKEN:-}" + } > /tmp/kirocrew-gateway-env + if [ "${KIROCREW_MOCK_GATEWAY:-serve}" = "hang" ]; then + exec sleep infinity + fi + mkdir -p "$HOME/.kiro/crew" + echo "test-secret" > "$HOME/.kiro/crew/.local_secret" + exec python3 /tmp/fake-gateway.py + ;; +esac +exit 0