diff --git a/.icons/openshell.svg b/.icons/openshell.svg
new file mode 100644
index 000000000..0dd7272ca
--- /dev/null
+++ b/.icons/openshell.svg
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/registry/coder-labs/templates/kubernetes-openshell/README.md b/registry/coder-labs/templates/kubernetes-openshell/README.md
new file mode 100644
index 000000000..8dbdd3ce9
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-openshell/README.md
@@ -0,0 +1,148 @@
+---
+display_name: OpenShell (Kubernetes)
+description: Run each workspace in an NVIDIA OpenShell sandbox, with a firewall by host name and program
+icon: ../../../../.icons/openshell.svg
+verified: false
+tags: [kubernetes, openshell, sandbox, firewall, kata, ai]
+---
+
+# OpenShell (Kubernetes)
+
+Run each workspace in an [NVIDIA OpenShell](https://docs.nvidia.com/openshell/latest/about/overview) sandbox. You and your AI agent work in the same sandbox, with one Coder agent named `dev`. OpenShell blocks all network access by default. Its proxy checks each connection against a policy that names the host and the program that opens it. It also limits file access with Landlock. By default the workspace can reach only the Coder server.
+
+## Why OpenShell
+
+- **Rules for each program:** a rule can let `git` reach `github.com` and still block `python3` from the same host. OpenShell logs each denied connection with the program name.
+- **Rules you can change live:** `openshell policy update` opens a host for a running sandbox, with no restart.
+- **File limits:** Landlock makes `/usr` and `/etc` read-only and denies every path that is not in the policy, such as `/home` and `/root`, even to code that runs as the workspace user.
+- **Kernel isolation:** with Kata, each sandbox is a microVM with its own kernel.
+
+If host names are enough, use [Plain Sandbox (Kubernetes)](https://registry.coder.com/templates/coder-labs/kubernetes-sandboxed). It has fewer parts and faster stops. OpenShell is at version 0.1, so pin the version and test each upgrade.
+
+## Startup speed
+
+With a prebuild, workspaces often start in about 2 seconds, which matters for agents that start a new workspace for each task. To see where the time goes, see [Speed tuning](#speed-tuning).
+
+## Prerequisites
+
+Required:
+
+- **A Kubernetes cluster** with a network plugin that enforces NetworkPolicy, a StorageClass for the home disk, and Linux 6.2 or later on the nodes, for Landlock.
+- **[agent-sandbox](https://github.com/kubernetes-sigs/agent-sandbox)** v1.0.4 or later, which the OpenShell Kubernetes driver uses.
+- **The cluster setup** in [`cluster/`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-openshell/cluster): a Terraform configuration that installs the OpenShell gateway and creates the namespace, the RBAC, and the gateway credentials. See [`cluster/README.md`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-openshell/cluster/README.md).
+- **The workspace image**, built from [`build/Dockerfile`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-openshell/build/Dockerfile).
+- **The launcher image**, built from [`build/launcher/Dockerfile`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-openshell/build/launcher/Dockerfile). It has only the `openshell` CLI.
+- **Access to the cluster** for the Coder provisioner. The template uses the in-cluster ServiceAccount, or `~/.kube/config` with `use_kubeconfig = true`. With the AI Gateway on, the provisioner also calls the Coder API at the access URL to create the workspace's token.
+
+Optional:
+
+- **Kata nodes**, for a microVM for each workspace: a node group with `/dev/kvm`. List them in `runtimes`. gVisor does not work, because it has no Landlock.
+- **Coder Premium**, for prebuilds and the AI Gateway. Without it, set `prebuild_instances = 0` and `enable_ai_gateway = false`, and give Claude Code another way to sign in.
+- **A DaemonSet that pulls the image** on every node.
+
+## Quick start
+
+1. Build both images and push them to your registry. See [Build the images](#build-the-images).
+2. Install agent-sandbox, and set up the cluster with the Terraform configuration in [`cluster/`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-openshell/cluster).
+3. Write the variables that `cluster/` prints to a file outside the template directory, add the images, and push the template:
+
+ ```sh
+ cd cluster
+ terraform output -raw coder_variables > ~/kubernetes-openshell.yaml
+ cd ..
+ cat >> ~/kubernetes-openshell.yaml << 'EOF'
+ workspace_image: registry.example.com/coder/workspace:v2.30.0
+ launcher_image: registry.example.com/coder/openshell-launcher:0.1.5
+ EOF
+ coder templates push kubernetes-openshell -d . --variables-file ~/kubernetes-openshell.yaml
+ ```
+
+Users pick the network profile and, if you set `runtimes`, the runtime when they create a workspace. They cannot change them later. Each profile except Custom is a preset with `prebuild_instances` prebuilds on the node's container runtime.
+
+## Build the images
+
+```sh
+docker buildx build --platform linux/amd64 \
+ --build-arg CODER_VERSION=2.30.0 \
+ -t registry.example.com/coder/workspace:v2.30.0 --push build
+docker buildx build --platform linux/amd64 \
+ --build-arg OPENSHELL_VERSION=0.1.5 \
+ -t registry.example.com/coder/openshell-launcher:0.1.5 --push build/launcher
+```
+
+- Match `CODER_VERSION` to your Coder server, and `OPENSHELL_VERSION` to your gateway. Rebuild when you upgrade either. `CODER_BINARY_URL`, such as `https://coder.example.com/bin/coder-linux-amd64`, copies the binary from your server instead of GitHub.
+- `INSTALL_CODE_SERVER=false` and `INSTALL_CLAUDE_CODE=false` leave out the tools. With `INSTALL_CLAUDE_CODE=false`, set `enable_claude_code = false` in the template.
+- Use a fixed tag or a digest. The template rejects `:latest`, because with `imagePullPolicy: IfNotPresent` the nodes keep an old copy.
+
+## Architecture
+
+Terraform cannot call the OpenShell gateway, so a small launcher Pod does it:
+
+1. **Start:** Terraform creates the home disk, a Secret with the agent token, and the launcher Pod. The launcher runs `openshell sandbox create` with the profile's policy, and the Coder agent is the main process of the sandbox.
+2. **Sandbox:** the gateway creates an agent-sandbox Sandbox, a supervisor Pod, and NetworkPolicies. The sandbox can talk only to its supervisor, so traffic cannot go around the proxy.
+3. **Stop:** Kubernetes stops the launcher, and the launcher deletes the sandbox. The home disk stays.
+
+- **Home:** Coder owns the disk, which is mounted at `/sandbox/home`. Files there survive a stop.
+- **Pods:** the sandbox runs as user 1000. OpenShell sets `cpu` and `memory` (1 CPU and 2 GiB by default) as both the request and the limit.
+- **Apps:** code-server, and Claude Code, which continues the last conversation in `/sandbox/home`.
+
+## Speed tuning
+
+In our Amazon EKS test cluster (`m7i.2xlarge` nodes, VPC CNI), from create to a connected agent:
+
+| Workspace | Ready in |
+| ------------------------------------ | ---------- |
+| From a prebuild | about 2 s |
+| No prebuild, Custom | about 14 s |
+| No prebuild, Kata (Cloud Hypervisor) | about 17 s |
+| Start after stop | about 24 s |
+| Stop | about 37 s |
+
+These all add to the speed:
+
+1. **Prebuilds** (Premium). A claim changes no Pod, so the sandbox keeps running, and the build takes about 1.5 s. GitHub profiles have already cloned `git_repo_url`.
+2. **The image.** [`build/Dockerfile`](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-openshell/build/Dockerfile) adds the Coder agent, code-server, and Claude Code, so a start downloads nothing, and it also works in Fully isolated. The image is about 800 MB compressed, so pull it ahead of time with a DaemonSet.
+3. **The disks and the supervisor.** A cold start waits for the home disk to attach and for the supervisor Pod. Stop waits until OpenShell deletes the sandbox.
+4. **Free CPU.** OpenShell reserves the full `cpu` for each sandbox, so a full cluster leaves new sandboxes Pending. Lower `cpu`, or add nodes.
+
+## Firewall
+
+The OpenShell proxy is the firewall. It runs in the supervisor, outside the workspace, so code in the workspace cannot change it. Each rule names a host, a port, and the programs that can use them. Everything else is denied and logged.
+
+- **Coder:** the Coder agent and Claude Code can reach the access URL, and so can every program they start, such as the shells in your terminal.
+- **Profile hosts:** every program can reach them.
+
+To open a host for a running workspace only:
+
+```sh
+openshell policy update "$SANDBOX" --rule-name extra \
+ --binary /usr/bin/curl --add-endpoint example.com:443 --wait
+```
+
+`$SANDBOX` is the sandbox name: `coder-` and the first 13 characters of the workspace ID without dashes. The change is gone after the next stop. To see blocked requests, run `openshell logs "$SANDBOX" --since 5m --source sandbox`.
+
+### Network ingress
+
+The sandbox accepts connections only from its supervisor. The Coder agent dials out to the Coder server, so the web terminal, code-server, `coder ssh`, port forwarding, and desktop IDEs all relay through Coder.
+
+### Network egress
+
+| Profile | Can reach, in addition to Coder |
+| ----------------------------- | ------------------------------------------------------------------- |
+| Fully isolated (default) | Nothing |
+| GitHub only | GitHub host names on 443 |
+| GitHub and package registries | GitHub, npm, the Go module proxy, and PyPI on 443 |
+| Custom | The host names and ports that you type. No preset, so a cold start. |
+
+There is no Public internet profile, because OpenShell 0.1.5 has no rule for any public host. With Custom, the form shows a list of host names and a choice of ports 443, 80, and 22. `*.example.com` matches one label, such as `api.example.com`, and `**.example.com` matches any depth. Neither matches `example.com` itself, so add it too if you need it. The form rejects IP addresses, names without a dot, `*.com`, and cluster names such as `.svc` or `.local`, and the build checks them again.
+
+## Limits
+
+- OpenShell decrypts HTTPS by default. It adds its own CA through `SSL_CERT_FILE` and similar variables, so it can read the traffic it allows.
+- The launcher uses the gateway's client certificate, which can manage every sandbox. Anyone who can read Secrets in the namespace can use it. Use OIDC in production.
+- OpenShell puts the agent token in plain text in the sandbox Pod and its Sandbox, so keep read access to the namespace tight.
+- If the launcher container exits, Kubernetes restarts it, and it replaces the sandbox. The home stays, but running programs stop.
+- Without Kata, the sandbox shares the node kernel, with Landlock and seccomp only.
+- An allowed host that stores user content, such as GitHub, can receive data from the workspace.
+- With the AI Gateway on, each start gives the workspace a new Coder token with only the `user:read_personal` scope, which the AI Gateway accepts. Code in the workspace can use it for AI requests. Coder cannot revoke it on stop, so it stays valid until the default token lifetime ends, and it shows in the owner's token list as `ai-gateway--`.
+- No root, no Docker, IPv4 only.
diff --git a/registry/coder-labs/templates/kubernetes-openshell/build/Dockerfile b/registry/coder-labs/templates/kubernetes-openshell/build/Dockerfile
new file mode 100644
index 000000000..91569d1be
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-openshell/build/Dockerfile
@@ -0,0 +1,47 @@
+FROM codercom/enterprise-base:ubuntu
+
+ARG TARGETARCH=amd64
+ARG CODER_VERSION=
+ARG CODER_BINARY_URL=
+ARG INSTALL_CODE_SERVER=true
+ARG CODE_SERVER_VERSION=4.105.1
+ARG INSTALL_CLAUDE_CODE=true
+
+USER root
+
+# The Coder agent.
+RUN set -eu; \
+ if [ -n "${CODER_BINARY_URL}" ]; then \
+ curl -fsSL --retry 5 -o /usr/local/bin/coder "${CODER_BINARY_URL}"; \
+ elif [ -n "${CODER_VERSION}" ]; then \
+ v="${CODER_VERSION#v}"; \
+ curl -fsSL --retry 5 "https://github.com/coder/coder/releases/download/v${v}/coder_${v}_linux_${TARGETARCH}.tar.gz" \
+ | tar -xz -C /usr/local/bin ./coder; \
+ else \
+ echo "Set CODER_VERSION or CODER_BINARY_URL" >&2; exit 1; \
+ fi; \
+ chmod 755 /usr/local/bin/coder; \
+ /usr/local/bin/coder version
+
+# code-server, outside /home so a home volume does not hide it. The
+# code-server module uses it with offline = true.
+RUN set -eu; \
+ if [ "${INSTALL_CODE_SERVER}" = true ]; then \
+ curl -fsSL https://code-server.dev/install.sh \
+ | sh -s -- --method=standalone --prefix=/opt/code-server --version "${CODE_SERVER_VERSION}"; \
+ fi
+
+# Claude Code, outside /home for the same reason. The template runs
+# /opt/claude/bin/claude.
+RUN set -eu; \
+ if [ "${INSTALL_CLAUDE_CODE}" = true ]; then \
+ export HOME=/opt/claude; \
+ mkdir -p /opt/claude/bin; \
+ curl -fsSL https://claude.ai/install.sh | bash; \
+ ln -sf "$(readlink -f /opt/claude/.local/bin/claude)" /opt/claude/bin/claude; \
+ chmod -R a+rX /opt/claude; \
+ /opt/claude/bin/claude --version; \
+ fi
+
+USER coder
+WORKDIR /home/coder
diff --git a/registry/coder-labs/templates/kubernetes-openshell/build/launcher/Dockerfile b/registry/coder-labs/templates/kubernetes-openshell/build/launcher/Dockerfile
new file mode 100644
index 000000000..6978cf064
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-openshell/build/launcher/Dockerfile
@@ -0,0 +1,14 @@
+FROM alpine:3.23@sha256:85fe1e81d6758c208f3e1eed4338a1997e19d4be002d4dd32d3100c9a8c010a0
+
+ARG TARGETARCH=amd64
+ARG OPENSHELL_VERSION=0.1.5
+
+# The openshell CLI. Use the version of your gateway.
+RUN set -eu; \
+ case "${TARGETARCH}" in amd64) arch=x86_64 ;; arm64) arch=aarch64 ;; *) echo "unsupported arch ${TARGETARCH}" >&2; exit 1 ;; esac; \
+ wget -qO- "https://github.com/NVIDIA/OpenShell/releases/download/v${OPENSHELL_VERSION}/openshell-${arch}-unknown-linux-musl.tar.gz" \
+ | tar -xz -C /usr/local/bin openshell; \
+ chmod 755 /usr/local/bin/openshell; \
+ /usr/local/bin/openshell --version
+
+USER 1000:1000
diff --git a/registry/coder-labs/templates/kubernetes-openshell/cluster/README.md b/registry/coder-labs/templates/kubernetes-openshell/cluster/README.md
new file mode 100644
index 000000000..8d8e2eeea
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-openshell/cluster/README.md
@@ -0,0 +1,41 @@
+# Cluster setup for kubernetes-openshell
+
+A cluster administrator sets up the cluster once. The Terraform configuration in this directory creates everything the template needs in the cluster:
+
+- The OpenShell gateway, from its Helm chart, in its own namespace.
+- The workspace namespace, where the gateway creates the sandboxes.
+- The gateway CA for the supervisor Pods, and the gateway client bundle for the launcher Pods, in the workspace namespace.
+- A Role and RoleBinding for the Coder provisioner. It can manage Pods, disks, and Secrets in the workspace namespace only.
+- The `openshell.ai/sandbox-attachable=true` label on the Kata RuntimeClasses in `runtimes`, so that OpenShell admits them.
+
+## 1. Install agent-sandbox
+
+The OpenShell Kubernetes driver creates agent-sandbox Sandboxes. Install the controller and the extensions, v1.0.4 or later:
+
+```sh
+kubectl apply -f https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v1.0.4/sandbox-with-extensions.yaml
+```
+
+For Kata, see [Install the runtimes](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-agent-sandbox/cluster/README.md#2-install-the-runtimes) in the agent-sandbox template.
+
+## 2. Apply
+
+Run Terraform from this directory with an account that can create namespaces, RBAC, and Helm releases:
+
+```sh
+terraform init
+terraform apply -var namespace=coder-workspaces
+```
+
+Set `runtimes`, for example `["kata-clh"]`, to offer Kata. Use the same value in the template. If the Coder provisioner does not run as the `coder` ServiceAccount in the `coder` namespace, set `provisioner_service_account`. `openshell_version` pins the Helm chart. Use the same version for the CLI in `build/launcher/Dockerfile`.
+
+Keep the Terraform state. Terraform needs it to change or remove the setup later.
+
+## 3. Push the template with the same values
+
+The `coder_variables` output has the namespace, the gateway URL, and the runtimes as a variables file for `coder templates push`. See the Quick start in the [template README](https://github.com/coder/registry/blob/main/registry/coder-labs/templates/kubernetes-openshell/README.md#quick-start).
+
+## Changes and removal
+
+- `terraform destroy` removes the gateway, both namespaces, and every workspace in them. It also removes the label from the RuntimeClasses in `runtimes`.
+- Removing a runtime from `runtimes` removes its label, so OpenShell no longer admits it. Remove it from the template first.
diff --git a/registry/coder-labs/templates/kubernetes-openshell/cluster/main.tf b/registry/coder-labs/templates/kubernetes-openshell/cluster/main.tf
new file mode 100644
index 000000000..8e5b4944e
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-openshell/cluster/main.tf
@@ -0,0 +1,233 @@
+# Cluster setup for the kubernetes-openshell template. A cluster
+# administrator applies this once. See README.md in this directory.
+
+terraform {
+ required_providers {
+ kubernetes = {
+ source = "hashicorp/kubernetes"
+ version = ">= 2.23"
+ }
+ helm = {
+ source = "hashicorp/helm"
+ version = ">= 3.0"
+ }
+ }
+}
+
+variable "kubeconfig_path" {
+ description = "The kubeconfig file to use."
+ type = string
+ default = "~/.kube/config"
+}
+
+variable "kubeconfig_context" {
+ description = "The kubeconfig context to use. Empty uses the current context."
+ type = string
+ default = ""
+}
+
+variable "gateway_namespace" {
+ description = "The namespace for the OpenShell gateway. Terraform creates it."
+ type = string
+ default = "openshell"
+
+ validation {
+ condition = can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", var.gateway_namespace)) && length(var.gateway_namespace) <= 63
+ error_message = "gateway_namespace must be a valid Kubernetes namespace name."
+ }
+}
+
+variable "gateway_name" {
+ description = "The Helm release name of the gateway, and the name of its Service."
+ type = string
+ default = "openshell"
+}
+
+variable "openshell_version" {
+ description = "The OpenShell Helm chart version. Use the same version for the CLI in build/launcher/Dockerfile."
+ type = string
+ default = "0.1.5"
+}
+
+variable "namespace" {
+ description = "The namespace for workspaces and their OpenShell sandboxes. Terraform creates it. Use the same value in the template."
+ type = string
+
+ validation {
+ condition = can(regex("^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", var.namespace)) && length(var.namespace) <= 63
+ error_message = "namespace must be a valid Kubernetes namespace name."
+ }
+}
+
+variable "runtimes" {
+ description = "The Kata RuntimeClasses that the template offers: kata-qemu, kata-clh. Terraform labels them so that OpenShell admits them. Use the same value in the template."
+ type = list(string)
+ default = []
+
+ validation {
+ condition = length(var.runtimes) == length(distinct(var.runtimes)) && alltrue([for r in var.runtimes : contains(["kata-qemu", "kata-clh"], r)])
+ error_message = "runtimes can list only kata-qemu and kata-clh, each once."
+ }
+}
+
+variable "provisioner_service_account" {
+ description = "The ServiceAccount of the Coder provisioner. The Coder Helm chart uses coder in the coder namespace."
+ type = object({
+ name = string
+ namespace = string
+ })
+ default = {
+ name = "coder"
+ namespace = "coder"
+ }
+}
+
+provider "kubernetes" {
+ config_path = var.kubeconfig_path
+ config_context = var.kubeconfig_context == "" ? null : var.kubeconfig_context
+}
+
+provider "helm" {
+ kubernetes = {
+ config_path = var.kubeconfig_path
+ config_context = var.kubeconfig_context == "" ? null : var.kubeconfig_context
+ }
+}
+
+resource "kubernetes_namespace_v1" "gateway" {
+ metadata {
+ name = var.gateway_namespace
+ }
+}
+
+resource "kubernetes_namespace_v1" "workspaces" {
+ metadata {
+ name = var.namespace
+ }
+}
+
+# The gateway runs sandboxes as uid 1000, the coder user of the image, and
+# accepts the driver config that mounts the Coder home disk.
+resource "helm_release" "gateway" {
+ name = var.gateway_name
+ namespace = kubernetes_namespace_v1.gateway.metadata[0].name
+ repository = "oci://ghcr.io/nvidia/openshell"
+ chart = "helm-chart"
+ version = var.openshell_version
+ wait = true
+
+ values = [yamlencode({
+ fullnameOverride = var.gateway_name
+ server = {
+ sandboxNamespace = kubernetes_namespace_v1.workspaces.metadata[0].name
+ sandboxUid = 1000
+ telemetryEnabled = false
+ drivers = {
+ kubernetes = {
+ allowDriverConfig = true
+ }
+ }
+ }
+ })]
+}
+
+# The gateway's certgen hook creates the client bundle in the gateway
+# namespace.
+data "kubernetes_secret_v1" "client_tls" {
+ depends_on = [helm_release.gateway]
+ metadata {
+ name = "openshell-client-tls"
+ namespace = kubernetes_namespace_v1.gateway.metadata[0].name
+ }
+}
+
+# Supervisor Pods mount ca.crt from this Secret and do not start without it.
+# It holds no key.
+resource "kubernetes_secret_v1" "sandbox_ca" {
+ metadata {
+ name = "openshell-client-tls"
+ namespace = kubernetes_namespace_v1.workspaces.metadata[0].name
+ }
+ data = {
+ "ca.crt" = data.kubernetes_secret_v1.client_tls.data["ca.crt"]
+ }
+}
+
+# The launcher Pod of each workspace uses the client bundle to call the
+# gateway. The template mounts it by this name.
+resource "kubernetes_secret_v1" "launcher_tls" {
+ metadata {
+ name = "openshell-launcher-tls"
+ namespace = kubernetes_namespace_v1.workspaces.metadata[0].name
+ }
+ type = "kubernetes.io/tls"
+ data = {
+ "ca.crt" = data.kubernetes_secret_v1.client_tls.data["ca.crt"]
+ "tls.crt" = data.kubernetes_secret_v1.client_tls.data["tls.crt"]
+ "tls.key" = data.kubernetes_secret_v1.client_tls.data["tls.key"]
+ }
+}
+
+# OpenShell admits only RuntimeClasses with this label.
+resource "kubernetes_labels" "runtime" {
+ for_each = toset(var.runtimes)
+ api_version = "node.k8s.io/v1"
+ kind = "RuntimeClass"
+ metadata {
+ name = each.value
+ }
+ # Takes over the label if kubectl label set it before.
+ force = true
+ labels = {
+ "openshell.ai/sandbox-attachable" = "true"
+ }
+}
+
+# The provisioner manages launcher Pods, home disks, and agent token Secrets
+# in the workspace namespace only.
+resource "kubernetes_role_v1" "provisioner" {
+ metadata {
+ name = "coder-kubernetes-openshell"
+ namespace = kubernetes_namespace_v1.workspaces.metadata[0].name
+ }
+ rule {
+ api_groups = [""]
+ resources = ["pods", "persistentvolumeclaims", "secrets"]
+ verbs = ["create", "delete", "get", "list", "patch", "update", "watch"]
+ }
+ # Read-only, to debug workspaces with kubectl as the provisioner.
+ rule {
+ api_groups = [""]
+ resources = ["pods/log", "events"]
+ verbs = ["get", "list", "watch"]
+ }
+}
+
+resource "kubernetes_role_binding_v1" "provisioner" {
+ metadata {
+ name = "coder-kubernetes-openshell"
+ namespace = kubernetes_namespace_v1.workspaces.metadata[0].name
+ }
+ role_ref {
+ api_group = "rbac.authorization.k8s.io"
+ kind = "Role"
+ name = kubernetes_role_v1.provisioner.metadata[0].name
+ }
+ subject {
+ kind = "ServiceAccount"
+ name = var.provisioner_service_account.name
+ namespace = var.provisioner_service_account.namespace
+ }
+}
+
+# A variables file for `coder templates push --variables-file`, so the
+# template uses the same values. Coder reads every value as a string, and
+# the template takes runtimes comma-separated.
+output "coder_variables" {
+ description = "The template variables for this setup, as YAML."
+ value = yamlencode({
+ namespace = kubernetes_namespace_v1.workspaces.metadata[0].name
+ openshell_gateway_url = "https://${var.gateway_name}.${var.gateway_namespace}.svc.cluster.local:8080"
+ runtimes = join(",", var.runtimes)
+ })
+}
diff --git a/registry/coder-labs/templates/kubernetes-openshell/cluster/tests/cluster.tftest.hcl b/registry/coder-labs/templates/kubernetes-openshell/cluster/tests/cluster.tftest.hcl
new file mode 100644
index 000000000..f2d421c54
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-openshell/cluster/tests/cluster.tftest.hcl
@@ -0,0 +1,106 @@
+mock_provider "kubernetes" {}
+
+mock_provider "helm" {}
+
+override_data {
+ target = data.kubernetes_secret_v1.client_tls
+ values = {
+ data = {
+ "ca.crt" = "test-ca"
+ "tls.crt" = "test-crt"
+ "tls.key" = "test-key"
+ }
+ }
+}
+
+variables {
+ namespace = "coder-workspaces"
+}
+
+run "defaults" {
+ command = plan
+
+ assert {
+ condition = helm_release.gateway.version == "0.1.5" && yamldecode(helm_release.gateway.values[0]).server.sandboxNamespace == "coder-workspaces" && yamldecode(helm_release.gateway.values[0]).server.sandboxUid == 1000
+ error_message = "The gateway must put sandboxes in the workspace namespace and run them as uid 1000."
+ }
+ assert {
+ condition = yamldecode(helm_release.gateway.values[0]).server.drivers.kubernetes.allowDriverConfig == true
+ error_message = "The gateway must accept the driver config that mounts the home disk."
+ }
+ assert {
+ condition = jsonencode(nonsensitive(keys(kubernetes_secret_v1.sandbox_ca.data))) == jsonencode(["ca.crt"])
+ error_message = "The supervisor Secret must hold only the CA, no key."
+ }
+ assert {
+ condition = kubernetes_role_binding_v1.provisioner.subject[0].name == "coder" && toset(kubernetes_role_v1.provisioner.rule[0].resources) == toset(["pods", "persistentvolumeclaims", "secrets"])
+ error_message = "The provisioner must manage only launcher Pods, home disks, and token Secrets."
+ }
+ assert {
+ condition = kubernetes_secret_v1.launcher_tls.metadata[0].namespace == "coder-workspaces" && kubernetes_secret_v1.launcher_tls.metadata[0].name == "openshell-launcher-tls" && jsonencode(nonsensitive(sort(keys(kubernetes_secret_v1.launcher_tls.data)))) == jsonencode(["ca.crt", "tls.crt", "tls.key"])
+ error_message = "The launcher bundle must land in the workspace namespace with the name that the template mounts."
+ }
+ assert {
+ condition = strcontains(output.coder_variables, "https://openshell.openshell.svc.cluster.local:8080")
+ error_message = "The output must have the gateway URL."
+ }
+ assert {
+ condition = length(kubernetes_labels.runtime) == 0
+ error_message = "With no runtimes, no RuntimeClass is labeled."
+ }
+}
+
+run "kata" {
+ command = plan
+
+ variables {
+ runtimes = ["kata-clh"]
+ }
+
+ assert {
+ condition = kubernetes_labels.runtime["kata-clh"].labels["openshell.ai/sandbox-attachable"] == "true"
+ error_message = "A listed runtime must get the label that OpenShell admits."
+ }
+ assert {
+ condition = strcontains(output.coder_variables, "runtimes: \"kata-clh\"") || strcontains(output.coder_variables, "\"runtimes\": \"kata-clh\"")
+ error_message = "The output must have the runtimes for the template."
+ }
+}
+
+run "gateway_elsewhere" {
+ command = plan
+
+ variables {
+ gateway_namespace = "sandbox-gateway"
+ gateway_name = "openshell-coder"
+ }
+
+ assert {
+ condition = helm_release.gateway.name == "openshell-coder" && helm_release.gateway.namespace == "sandbox-gateway" && yamldecode(helm_release.gateway.values[0]).fullnameOverride == "openshell-coder"
+ error_message = "The gateway must use the given name and namespace."
+ }
+ assert {
+ condition = strcontains(output.coder_variables, "https://openshell-coder.sandbox-gateway.svc.cluster.local:8080")
+ error_message = "The gateway URL must follow the name and namespace."
+ }
+}
+
+run "rejects_gvisor" {
+ command = plan
+
+ variables {
+ runtimes = ["gvisor"]
+ }
+
+ expect_failures = [var.runtimes]
+}
+
+run "rejects_bad_namespace" {
+ command = plan
+
+ variables {
+ namespace = "Coder_Workspaces"
+ }
+
+ expect_failures = [var.namespace]
+}
diff --git a/registry/coder-labs/templates/kubernetes-openshell/launcher.sh b/registry/coder-labs/templates/kubernetes-openshell/launcher.sh
new file mode 100644
index 000000000..998e2073f
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-openshell/launcher.sh
@@ -0,0 +1,71 @@
+#!/bin/sh
+# POSIX sh, because the launcher image is Alpine with no bash.
+#
+# Creates the OpenShell sandbox of a workspace and deletes it on stop.
+# main.tf runs it inline with sh -c in the launcher Pod, and passes the
+# values as environment variables.
+set -eu
+
+mkdir -p "$HOME/.config/openshell/gateways/coder/mtls"
+cp /openshell-tls/ca.crt /openshell-tls/tls.crt /openshell-tls/tls.key "$HOME/.config/openshell/gateways/coder/mtls/"
+openshell gateway add "$OPENSHELL_GATEWAY_URL" --local --name coder > /dev/null
+printf '%s' "$SANDBOX_POLICY" > "$HOME/policy.yaml"
+
+# Each start creates a new sandbox. Remove one that a launcher left when it
+# did not shut down cleanly.
+openshell sandbox delete "$SANDBOX_NAME" > /dev/null 2>&1 || true
+
+# create runs in the background, so the TERM trap runs at once, even while
+# a create waits for compute.
+pid=""
+cleanup() {
+ if [ -n "$pid" ]; then
+ kill "$pid" 2> /dev/null || true
+ wait "$pid" 2> /dev/null || true
+ fi
+ echo "deleting sandbox $SANDBOX_NAME"
+ openshell sandbox delete "$SANDBOX_NAME" || true
+ exit 0
+}
+trap cleanup TERM INT
+
+create() {
+ openshell sandbox create \
+ --name "$SANDBOX_NAME" \
+ --from "$SANDBOX_IMAGE" \
+ --cpu "$SANDBOX_CPU" \
+ --memory "$SANDBOX_MEMORY" \
+ --policy "$HOME/policy.yaml" \
+ --driver-config-json "$SANDBOX_DRIVER_CONFIG" \
+ --label "coder-workspace-id=$CODER_WORKSPACE_ID" \
+ --env HOME="$SANDBOX_HOME" \
+ --env CODER_AGENT_URL="$CODER_AGENT_URL" \
+ --env CODER_AGENT_AUTH=token \
+ --env-from CODER_AGENT_TOKEN \
+ --no-credential-warnings \
+ --no-auto-providers \
+ --detach --no-tty \
+ -- sh -c "$SANDBOX_COMMAND"
+}
+
+# Retry until the sandbox starts. A create can time out, for example when
+# the nodes are out of CPU. Without a retry, the agent never connects.
+while true; do
+ create &
+ pid=$!
+ if wait "$pid"; then
+ pid=""
+ break
+ fi
+ pid=""
+ echo "sandbox create failed, retrying in 10 s"
+ openshell sandbox delete "$SANDBOX_NAME" > /dev/null 2>&1 || true
+ sleep 10 &
+ wait $!
+done
+
+# Wait in the background, so the TERM trap runs at once.
+while true; do
+ sleep 30 &
+ wait $!
+done
diff --git a/registry/coder-labs/templates/kubernetes-openshell/main.tf b/registry/coder-labs/templates/kubernetes-openshell/main.tf
new file mode 100644
index 000000000..7878a590c
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-openshell/main.tf
@@ -0,0 +1,761 @@
+terraform {
+ required_providers {
+ coder = {
+ source = "coder/coder"
+ version = ">= 2.12"
+ }
+ kubernetes = {
+ source = "hashicorp/kubernetes"
+ version = ">= 2.23"
+ }
+ http = {
+ source = "hashicorp/http"
+ version = ">= 3.4"
+ }
+ }
+}
+
+provider "coder" {}
+
+variable "use_kubeconfig" {
+ description = <<-EOF
+ Use host kubeconfig? (true/false)
+
+ Set this to false if the Coder host is itself running as a Pod on the same
+ Kubernetes cluster as you are deploying workspaces to.
+
+ Set this to true if the Coder host is running outside the Kubernetes cluster
+ for workspaces. A valid "~/.kube/config" must be present on the Coder host.
+ EOF
+ type = bool
+ default = false
+}
+
+variable "namespace" {
+ description = "The namespace for workspaces, which is the gateway's sandbox namespace. cluster/ creates it with the Role and the gateway credentials."
+ type = string
+
+ validation {
+ condition = length(trimspace(var.namespace)) > 0
+ error_message = "namespace must not be empty."
+ }
+}
+
+variable "workspace_image" {
+ description = "The workspace image. Build it from build/Dockerfile, with a Coder agent that matches your Coder server. Use a fixed tag or a digest, not :latest."
+ type = string
+
+ validation {
+ condition = (
+ length(trimspace(var.workspace_image)) > 0 &&
+ !endswith(var.workspace_image, ":latest") &&
+ can(regex("[:@]", element(split("/", var.workspace_image), length(split("/", var.workspace_image)) - 1)))
+ )
+ error_message = "workspace_image must have a tag other than :latest, or a digest, such as registry.example.com/coder/workspace:v2.30.0."
+ }
+}
+
+variable "launcher_image" {
+ description = "The launcher image with the openshell CLI. Build it from build/launcher/Dockerfile, with the CLI version of your gateway. Use a fixed tag or a digest, not :latest."
+ type = string
+
+ validation {
+ condition = (
+ length(trimspace(var.launcher_image)) > 0 &&
+ !endswith(var.launcher_image, ":latest") &&
+ can(regex("[:@]", element(split("/", var.launcher_image), length(split("/", var.launcher_image)) - 1)))
+ )
+ error_message = "launcher_image must have a tag other than :latest, or a digest, such as registry.example.com/coder/openshell-launcher:0.1.5."
+ }
+}
+
+variable "openshell_gateway_url" {
+ description = "The URL of the OpenShell gateway, as the launcher Pods in the namespace see it."
+ type = string
+ default = "https://openshell.openshell.svc.cluster.local:8080"
+}
+
+variable "openshell_workspace" {
+ description = "The OpenShell workspace that owns the sandboxes."
+ type = string
+ default = "default"
+}
+
+# A comma-separated string, not a list, because Coder checks presets with
+# every template variable as a string.
+variable "runtimes" {
+ description = "Kata runtimes that users can pick in addition to the node's container runtime, comma-separated: kata-qemu, kata-clh. Each RuntimeClass needs the label openshell.ai/sandbox-attachable=true, which cluster/ adds. gVisor does not work, because it has no Landlock. Empty offers none."
+ type = string
+ default = ""
+
+ validation {
+ condition = (
+ length(compact(split(",", replace(var.runtimes, " ", "")))) == length(distinct(compact(split(",", replace(var.runtimes, " ", ""))))) &&
+ alltrue([for r in compact(split(",", replace(var.runtimes, " ", ""))) : contains(["kata-qemu", "kata-clh"], r)])
+ )
+ error_message = "runtimes can list only kata-qemu and kata-clh, comma-separated, each once."
+ }
+}
+
+variable "cpu" {
+ description = "The CPU of each sandbox, as a Kubernetes quantity such as 1 or 500m. OpenShell sets it as both the request and the limit."
+ type = string
+ default = "1"
+
+ validation {
+ condition = can(regex("^[0-9]+(\\.[0-9]+)?m?$", var.cpu))
+ error_message = "cpu must be a Kubernetes CPU quantity, such as 1, 1.5, or 500m."
+ }
+}
+
+variable "memory" {
+ description = "The memory of each sandbox, as a Kubernetes quantity such as 2Gi. OpenShell sets it as both the request and the limit."
+ type = string
+ default = "2Gi"
+
+ validation {
+ condition = can(regex("^[0-9]+(Mi|Gi)$", var.memory))
+ error_message = "memory must be a Kubernetes quantity in Mi or Gi, such as 2Gi."
+ }
+}
+
+variable "home_disk_size" {
+ description = "The size of the persistent home disk in GiB."
+ type = number
+ default = 10
+
+ validation {
+ condition = var.home_disk_size >= 1
+ error_message = "home_disk_size must be at least 1."
+ }
+}
+
+variable "storage_class_name" {
+ description = "The StorageClass for the persistent home disk. Leave empty to use the cluster default. Use a class with volumeBindingMode WaitForFirstConsumer."
+ type = string
+ default = ""
+}
+
+variable "prebuild_instances" {
+ description = "The number of prebuilt workspaces to keep for each preset. Prebuilds need a Coder Premium license. Set 0 to turn them off."
+ type = number
+ default = 1
+
+ validation {
+ condition = var.prebuild_instances >= 0 && floor(var.prebuild_instances) == var.prebuild_instances
+ error_message = "prebuild_instances must be a whole number, 0 or more."
+ }
+}
+
+variable "git_repo_url" {
+ description = "A Git repository to clone into the home over HTTPS, for example https://github.com/coder/coder. Only profiles that allow GitHub clone it. Empty clones nothing."
+ type = string
+ default = ""
+}
+
+variable "enable_ai_gateway" {
+ description = "Send Claude Code requests through the Coder AI Gateway (Premium). Claude Code then reaches Anthropic through the Coder server, so it works in Fully isolated."
+ type = bool
+ default = true
+}
+
+variable "enable_claude_code" {
+ description = "Add the Claude Code app and its settings. Turn this off if you build the image with INSTALL_CLAUDE_CODE=false."
+ type = bool
+ default = true
+}
+
+provider "kubernetes" {
+ config_path = var.use_kubeconfig ? "~/.kube/config" : null
+}
+
+data "coder_workspace" "me" {}
+data "coder_workspace_owner" "me" {}
+
+# Network profiles: what a workspace can reach in addition to the Coder
+# server. The OpenShell proxy matches host names. Do not add hosts that
+# store user content, because the workspace can send data to them.
+locals {
+ github_hosts = [
+ "github.com",
+ "api.github.com",
+ "codeload.github.com",
+ "objects.githubusercontent.com",
+ "raw.githubusercontent.com",
+ "release-assets.githubusercontent.com",
+ ]
+
+ profiles = {
+ isolated = {
+ display_name = "Fully isolated"
+ description = "Coder only. No internet."
+ icon = "/emojis/1f512.png"
+ hosts = []
+ }
+ github = {
+ display_name = "GitHub only"
+ description = "Coder and GitHub on port 443."
+ icon = "/icon/github.svg"
+ hosts = local.github_hosts
+ }
+ development = {
+ display_name = "GitHub and package registries"
+ description = "Coder, GitHub, npm, the Go module proxy, and PyPI on port 443."
+ icon = "/emojis/1f4e6.png"
+ hosts = concat(local.github_hosts, [
+ "registry.npmjs.org",
+ "proxy.golang.org",
+ "sum.golang.org",
+ "storage.googleapis.com",
+ "pypi.org",
+ "files.pythonhosted.org",
+ ])
+ }
+ custom = {
+ display_name = "Custom"
+ description = "Coder and the host names and ports that you type below."
+ icon = "/emojis/270f.png"
+ hosts = []
+ }
+ }
+
+ # Profiles that can reach GitHub, so they clone git_repo_url.
+ github_profiles = ["github", "development"]
+
+ runtimes = compact(split(",", replace(var.runtimes, " ", "")))
+ runtime_options = {
+ kata-qemu = {
+ name = "Kata microVM (QEMU)"
+ description = "A small VM with its own kernel, around the OpenShell sandbox. Needs a node with /dev/kvm."
+ icon = "/icon/memory.svg"
+ }
+ kata-clh = {
+ name = "Kata microVM (Cloud Hypervisor)"
+ description = "A small VM with a lighter hypervisor. Needs a node with /dev/kvm."
+ icon = "/icon/memory.svg"
+ }
+ }
+}
+
+data "coder_parameter" "network_isolation" {
+ name = "network_isolation"
+ display_name = "Network isolation"
+ description = "What the workspace can reach in addition to Coder. The OpenShell proxy allows it by host name and program, and denies and logs everything else. You cannot change it after create."
+ type = "string"
+ form_type = "dropdown"
+ default = "isolated"
+ mutable = false
+ order = 1
+
+ dynamic "option" {
+ for_each = local.profiles
+ content {
+ name = option.value.display_name
+ description = option.value.description
+ value = option.key
+ icon = option.value.icon
+ }
+ }
+}
+
+# The Custom profile. These parameters appear only when you pick Custom.
+locals {
+ custom = data.coder_parameter.network_isolation.value == "custom"
+ custom_hosts = local.custom ? distinct([for h in jsondecode(data.coder_parameter.custom_hosts[0].value) : lower(trimspace(h)) if trimspace(h) != ""]) : []
+ custom_ports = local.custom ? distinct([for p in jsondecode(data.coder_parameter.custom_ports[0].value) : tonumber(p)]) : []
+
+ # An exact name with a dot, or "*." or "**." and at least two more labels.
+ # No IP addresses. OpenShell lets an exact name reach the private address
+ # it resolves to, so cluster and internal suffixes are blocked.
+ host_label = "[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?"
+ host_pattern = "^(\\*{1,2}\\.)?(${local.host_label}\\.)+[a-z][a-z0-9-]*[a-z0-9]$"
+ blocked_suffixes = ["localhost", "local", "internal", "cluster.local", "svc", "localdomain"]
+ custom_bad = [
+ for h in local.custom_hosts : h
+ if !can(regex(local.host_pattern, h)) ||
+ anytrue([for b in local.blocked_suffixes : h == b || endswith(h, ".${b}")])
+ ]
+ custom_problems = local.custom ? length(local.custom_bad) + (length(local.custom_hosts) == 0 ? 1 : 0) + (length(local.custom_ports) == 0 ? 1 : 0) : 0
+}
+
+data "coder_parameter" "custom_hosts" {
+ count = local.custom ? 1 : 0
+ name = "custom_hosts"
+ display_name = "Custom: host names"
+ description = "Type a host name such as api.example.com, *.example.com, or **.example.com and press Enter. *. matches one label and **. matches any depth, and neither matches example.com itself. Every program in the workspace can reach these hosts on the ports below."
+ type = "list(string)"
+ form_type = "tag-select"
+ default = jsonencode(["github.com", "api.github.com", "registry.npmjs.org"])
+ mutable = false
+ order = 2
+}
+
+data "coder_parameter" "custom_ports" {
+ count = local.custom ? 1 : 0
+ name = "custom_ports"
+ display_name = "Custom: TCP ports"
+ description = "The ports that the workspace can use on the hosts above."
+ type = "list(string)"
+ form_type = "multi-select"
+ default = jsonencode(["443"])
+ mutable = false
+ order = 3
+
+ option {
+ name = "443 (HTTPS)"
+ value = "443"
+ }
+ option {
+ name = "80 (HTTP)"
+ value = "80"
+ }
+ option {
+ name = "22 (SSH)"
+ value = "22"
+ }
+}
+
+# Shows the problems in the Custom fields while there are any. Its
+# validation blocks Create, so the form reports them before the build.
+data "coder_parameter" "custom_problems" {
+ count = local.custom_problems > 0 ? 1 : 0
+ name = "custom_problems"
+ display_name = "Custom: problems to fix"
+ type = "number"
+ form_type = "input"
+ default = local.custom_problems
+ mutable = true
+ order = 4
+ styling = jsonencode({ disabled = true })
+
+ validation {
+ min = 0
+ max = 0
+ error = length(local.custom_hosts) == 0 ? "Type at least one host name." : length(local.custom_ports) == 0 ? "Pick at least one port." : "Not a host name that OpenShell can allow: ${join(", ", local.custom_bad)}."
+ }
+}
+
+data "coder_parameter" "runtime" {
+ count = length(local.runtimes) > 0 ? 1 : 0
+ name = "runtime"
+ display_name = "Kernel isolation"
+ description = "The RuntimeClass of the sandbox Pod. The network and file rules are the same for every option."
+ type = "string"
+ form_type = "dropdown"
+ default = "none"
+ mutable = false
+ order = 5
+
+ option {
+ name = "None"
+ value = "none"
+ description = "The node's container runtime, with Landlock and seccomp. The sandbox shares the node kernel. The fastest."
+ icon = "/icon/kubernetes.svg"
+ }
+ dynamic "option" {
+ for_each = local.runtimes
+ content {
+ name = local.runtime_options[option.value].name
+ value = option.value
+ description = local.runtime_options[option.value].description
+ icon = local.runtime_options[option.value].icon
+ }
+ }
+}
+
+# One preset for each fixed profile, on the node's container runtime. A
+# preset sets every immutable parameter, so a claimed prebuild never
+# changes one.
+data "coder_workspace_preset" "profile" {
+ for_each = { for k, v in local.profiles : k => v if k != "custom" }
+ name = each.value.display_name
+ description = each.value.description
+ icon = each.value.icon
+ default = each.key == "isolated"
+ parameters = merge(
+ { network_isolation = each.key },
+ length(local.runtimes) > 0 ? { runtime = "none" } : {},
+ )
+
+ dynamic "prebuilds" {
+ for_each = var.prebuild_instances > 0 ? [1] : []
+ content {
+ instances = var.prebuild_instances
+ }
+ }
+}
+
+locals {
+ name = "coder-${data.coder_workspace.me.id}"
+ runtime = try(data.coder_parameter.runtime[0].value, "none")
+ profile = local.profiles[data.coder_parameter.network_isolation.value]
+ # OpenShell sandbox names have at most 19 characters.
+ sandbox_name = "coder-${substr(replace(data.coder_workspace.me.id, "-", ""), 0, 13)}"
+ home = "/sandbox/home"
+
+ # Labels stay the same when a prebuilt workspace is claimed, so a claim
+ # does not change the launcher Pod.
+ labels = {
+ "app.kubernetes.io/name" = "coder-workspace"
+ "app.kubernetes.io/part-of" = "coder"
+ "com.coder.resource" = "true"
+ "com.coder.workspace.id" = data.coder_workspace.me.id
+ }
+ owner_annotations = {
+ "com.coder.workspace.name" = data.coder_workspace.me.name
+ "com.coder.user.id" = data.coder_workspace_owner.me.id
+ "com.coder.user.username" = data.coder_workspace_owner.me.name
+ }
+
+ # The agent dials the access URL, so the policy always allows it.
+ access_url = regex("^(?Phttps?)://(?P[^:/]+)(?::(?P[0-9]+))?", data.coder_workspace.me.access_url)
+ access_port = tonumber(coalesce(local.access_url.port, local.access_url.scheme == "https" ? "443" : "80"))
+
+ # The OpenShell sandbox policy. A network rule names the programs that may
+ # use it, and a program started by a listed program gets its rules, so
+ # every shell and tool that the agent starts can reach Coder. OpenShell
+ # matches the resolved path. Profile hosts are open to every program.
+ egress_hosts = local.custom ? local.custom_hosts : local.profile.hosts
+ egress_ports = local.custom ? local.custom_ports : [443]
+ policy = yamlencode({
+ version = 1
+ filesystem_policy = {
+ include_workdir = true
+ read_only = ["/bin", "/sbin", "/usr", "/lib", "/lib64", "/etc", "/opt", "/proc", "/sys", "/dev/urandom", "/var"]
+ read_write = ["/tmp", "/dev/null", "/dev/pts", "/dev/ptmx", "/dev/tty"]
+ }
+ network_policies = merge(
+ {
+ coder = {
+ endpoints = [{ host = local.access_url.host, port = local.access_port }]
+ binaries = [
+ { path = "/usr/local/bin/coder" },
+ { path = "/opt/claude/**" },
+ ]
+ }
+ },
+ length(local.egress_hosts) > 0 ? {
+ profile = {
+ endpoints = flatten([for h in local.egress_hosts : [for p in local.egress_ports : { host = h, port = p }]])
+ binaries = [{ path = "/**" }]
+ }
+ } : {}
+ )
+ })
+
+ # Mounting the home disk under /sandbox replaces the volume that OpenShell
+ # creates otherwise, so Coder owns the only disk.
+ driver_config = jsonencode({
+ kubernetes = merge(
+ {
+ volumes = [{
+ name = "home"
+ persistent_volume_claim = { claim_name = kubernetes_persistent_volume_claim_v1.home.metadata[0].name, read_only = false }
+ }]
+ containers = {
+ agent = {
+ volume_mounts = [{ name = "home", mount_path = local.home, read_only = false }]
+ }
+ }
+ },
+ local.runtime != "none" ? { pod = { runtime_class_name = local.runtime } } : {}
+ )
+ })
+ # Copy the image's dotfiles into a new, empty home, then start the agent
+ # as the main process of the sandbox.
+ agent_command = "[ -e ${local.home}/.bashrc ] || cp -an /etc/skel/. ${local.home}/ 2>/dev/null || true; exec /usr/local/bin/coder agent"
+
+}
+
+resource "coder_agent" "dev" {
+ os = "linux"
+ arch = "amd64"
+ dir = local.home
+
+ 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 ${local.home}"
+ interval = 60
+ timeout = 1
+ }
+ metadata {
+ display_name = "Kernel"
+ key = "3_kernel"
+ script = "uname -r"
+ interval = 600
+ timeout = 1
+ }
+}
+
+# The same check as custom_problems, for builds that do not come from the
+# form, such as the API or the CLI.
+resource "terraform_data" "custom_check" {
+ count = local.custom ? 1 : 0
+
+ lifecycle {
+ precondition {
+ condition = local.custom_problems == 0
+ error_message = "Custom needs at least one valid host name and one port. Not valid: ${join(", ", local.custom_bad)}"
+ }
+ }
+}
+
+# Coder owns the home disk, so it outlives the sandbox. The labels let
+# OpenShell attach it.
+resource "kubernetes_persistent_volume_claim_v1" "home" {
+ metadata {
+ name = "${local.name}-home"
+ namespace = var.namespace
+ labels = merge(local.labels, {
+ "openshell.ai/sandbox-attachable" = "true"
+ "openshell.ai/sandbox-attachable-workspace" = var.openshell_workspace
+ })
+ annotations = local.owner_annotations
+ }
+ wait_until_bound = false
+ spec {
+ access_modes = ["ReadWriteOnce"]
+ storage_class_name = var.storage_class_name != "" ? var.storage_class_name : null
+ resources {
+ requests = {
+ storage = "${var.home_disk_size}Gi"
+ }
+ }
+ }
+}
+
+# Only while the workspace is started. The launcher reads the agent token
+# from it, so the token is not in the launcher Pod spec. OpenShell puts it
+# in the sandbox Pod spec.
+resource "kubernetes_secret_v1" "agent_token" {
+ count = data.coder_workspace.me.start_count
+ metadata {
+ name = "${local.name}-agent-token"
+ namespace = var.namespace
+ labels = local.labels
+ }
+ data = {
+ token = coder_agent.dev.token
+ }
+}
+
+# Terraform cannot call the OpenShell gateway, so a small launcher Pod
+# creates the sandbox when the workspace starts and deletes it when the
+# workspace stops. The gateway creates the sandbox Pods.
+resource "kubernetes_pod_v1" "launcher" {
+ count = data.coder_workspace.me.start_count
+ depends_on = [terraform_data.custom_check]
+
+ metadata {
+ name = "${local.name}-launcher"
+ namespace = var.namespace
+ labels = local.labels
+ }
+ spec {
+ automount_service_account_token = false
+ enable_service_links = false
+ # The launcher deletes the sandbox on SIGTERM, which took about 37 s.
+ termination_grace_period_seconds = 120
+ # A restarted launcher replaces the sandbox, so a crash costs the
+ # running session but not the home.
+ restart_policy = "Always"
+ security_context {
+ run_as_non_root = true
+ run_as_user = 1000
+ run_as_group = 1000
+ seccomp_profile {
+ type = "RuntimeDefault"
+ }
+ }
+ container {
+ name = "launcher"
+ image = var.launcher_image
+ image_pull_policy = "IfNotPresent"
+ command = ["sh", "-c", file("${path.module}/launcher.sh")]
+ security_context {
+ allow_privilege_escalation = false
+ read_only_root_filesystem = true
+ capabilities {
+ drop = ["ALL"]
+ }
+ }
+ dynamic "env" {
+ for_each = {
+ HOME = "/tmp/launcher"
+ OPENSHELL_GATEWAY_URL = var.openshell_gateway_url
+ OPENSHELL_WORKSPACE = var.openshell_workspace
+ SANDBOX_NAME = local.sandbox_name
+ SANDBOX_IMAGE = var.workspace_image
+ SANDBOX_CPU = var.cpu
+ SANDBOX_MEMORY = var.memory
+ SANDBOX_POLICY = local.policy
+ SANDBOX_DRIVER_CONFIG = local.driver_config
+ SANDBOX_HOME = local.home
+ SANDBOX_COMMAND = local.agent_command
+ CODER_WORKSPACE_ID = data.coder_workspace.me.id
+ CODER_AGENT_URL = data.coder_workspace.me.access_url
+ }
+ content {
+ name = env.key
+ value = env.value
+ }
+ }
+ env {
+ name = "CODER_AGENT_TOKEN"
+ value_from {
+ secret_key_ref {
+ name = kubernetes_secret_v1.agent_token[0].metadata[0].name
+ key = "token"
+ }
+ }
+ }
+ resources {
+ requests = { cpu = "10m", memory = "32Mi" }
+ limits = { cpu = "200m", memory = "128Mi" }
+ }
+ volume_mount {
+ name = "openshell-tls"
+ mount_path = "/openshell-tls"
+ read_only = true
+ }
+ volume_mount {
+ name = "tmp"
+ mount_path = "/tmp"
+ }
+ }
+ volume {
+ name = "openshell-tls"
+ # cluster/ creates it with this name.
+ secret {
+ secret_name = "openshell-launcher-tls"
+ }
+ }
+ volume {
+ name = "tmp"
+ empty_dir {}
+ }
+ }
+}
+
+# code-server and Claude Code are in the image, so the modules download
+# nothing and work in Fully isolated.
+module "code-server" {
+ count = data.coder_workspace.me.start_count
+ source = "registry.coder.com/coder/code-server/coder"
+ version = "1.6.0"
+ agent_id = coder_agent.dev.id
+ folder = local.home
+ offline = true
+ install_prefix = "/opt/code-server"
+ order = 1
+}
+
+# A prebuild clones the repository before anyone claims it, and the module
+# skips the clone when the folder exists, so a claim does not wait for it.
+module "git-clone" {
+ count = data.coder_workspace.me.start_count != 0 && var.git_repo_url != "" && contains(local.github_profiles, data.coder_parameter.network_isolation.value) ? 1 : 0
+ source = "registry.coder.com/coder/git-clone/coder"
+ version = "2.0.5"
+ agent_id = coder_agent.dev.id
+ url = var.git_repo_url
+ base_dir = local.home
+}
+
+# Claude Code is in the image, so the template only sets its environment.
+# The claude-code registry module does the same, but it reads the
+# deprecated coder_task data source, which adds a warning to every build.
+resource "coder_env" "claude_disable_autoupdater" {
+ count = var.enable_claude_code ? data.coder_workspace.me.start_count : 0
+ agent_id = coder_agent.dev.id
+ name = "DISABLE_AUTOUPDATER"
+ value = "1"
+}
+
+# With the AI Gateway, Claude Code reaches Anthropic through the Coder
+# server. It does not get the owner's session token, which has full access
+# to the Coder API. The build uses that token once to create a token with
+# only the user:read_personal scope, which the AI Gateway accepts, and gives
+# the workspace that one. timestamp() defers the request to apply, so a plan
+# creates nothing, and a prebuild gets its token when it is claimed.
+#
+# Coder deletes the session token before a stop build, so Terraform cannot
+# revoke the scoped token on stop. It expires after the deployment's
+# default token lifetime, and each start creates a new one.
+locals {
+ mint_ai_gateway_token = (
+ var.enable_ai_gateway && var.enable_claude_code &&
+ data.coder_workspace.me.start_count > 0 &&
+ !data.coder_workspace.me.is_prebuild &&
+ data.coder_workspace_owner.me.session_token != ""
+ )
+}
+
+data "http" "ai_gateway_token" {
+ count = local.mint_ai_gateway_token ? 1 : 0
+ url = "${data.coder_workspace.me.access_url}/api/v2/users/me/keys/tokens"
+ method = "POST"
+ request_headers = {
+ "Coder-Session-Token" = data.coder_workspace_owner.me.session_token
+ "Content-Type" = "application/json"
+ }
+ request_body = jsonencode({
+ token_name = "ai-gateway-${substr(data.coder_workspace.me.id, 0, 8)}-${formatdate("YYYYMMDDhhmmss", timestamp())}"
+ scopes = ["user:read_personal"]
+ })
+
+ lifecycle {
+ postcondition {
+ condition = self.status_code == 201
+ error_message = "Could not create the AI Gateway token: HTTP ${self.status_code}."
+ }
+ }
+}
+
+resource "coder_env" "anthropic_base_url" {
+ count = var.enable_ai_gateway && var.enable_claude_code ? data.coder_workspace.me.start_count : 0
+ agent_id = coder_agent.dev.id
+ name = "ANTHROPIC_BASE_URL"
+ value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/anthropic"
+}
+
+resource "coder_env" "anthropic_auth_token" {
+ count = local.mint_ai_gateway_token ? 1 : 0
+ agent_id = coder_agent.dev.id
+ name = "ANTHROPIC_AUTH_TOKEN"
+ value = sensitive(jsondecode(data.http.ai_gateway_token[0].response_body).key)
+}
+
+# Opens Claude Code in the home. It continues the last conversation in this
+# folder, or starts a new one if there is none. In tmux, if the image has
+# it, the session outlives the browser tab.
+resource "coder_app" "claude" {
+ count = var.enable_claude_code ? data.coder_workspace.me.start_count : 0
+ agent_id = coder_agent.dev.id
+ slug = "claude-code"
+ display_name = "Claude Code"
+ icon = "/icon/claude.svg"
+ order = 2
+ command = <<-EOT
+ cd ${local.home}
+ run="/opt/claude/bin/claude --continue || /opt/claude/bin/claude"
+ if command -v tmux >/dev/null 2>&1; then
+ exec tmux new-session -A -s claude "$run"
+ fi
+ eval "$run"
+ EOT
+}
diff --git a/registry/coder-labs/templates/kubernetes-openshell/tests/template.tftest.hcl b/registry/coder-labs/templates/kubernetes-openshell/tests/template.tftest.hcl
new file mode 100644
index 000000000..78c7f156b
--- /dev/null
+++ b/registry/coder-labs/templates/kubernetes-openshell/tests/template.tftest.hcl
@@ -0,0 +1,467 @@
+mock_provider "coder" {
+ mock_data "coder_workspace" {
+ defaults = {
+ id = "0b6b6a5e-0000-4000-8000-000000000001"
+ name = "test"
+ start_count = 1
+ transition = "start"
+ access_url = "https://coder.example.com"
+ }
+ }
+ mock_data "coder_workspace_owner" {
+ defaults = {
+ id = "0b6b6a5e-0000-4000-8000-000000000002"
+ name = "owner"
+ email = "owner@example.org"
+ session_token = "owner-full-session-token"
+ }
+ }
+}
+
+mock_provider "kubernetes" {}
+
+mock_provider "http" {
+ mock_data "http" {
+ defaults = {
+ status_code = 201
+ response_body = "{\"key\":\"scoped-id-scoped-secret\"}"
+ }
+ }
+}
+
+override_module {
+ target = module.code-server
+ outputs = {}
+}
+
+variables {
+ namespace = "coder-workspaces"
+ workspace_image = "registry.example.com/coder/workspace:v2.30.0"
+ launcher_image = "registry.example.com/coder/openshell-launcher:0.1.5"
+}
+
+override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "isolated" }
+}
+
+run "isolated" {
+ command = plan
+
+ assert {
+ condition = length(kubernetes_pod_v1.launcher) == 1 && kubernetes_persistent_volume_claim_v1.home.spec[0].resources[0].requests.storage == "10Gi"
+ error_message = "A start must create the launcher Pod and a 10Gi home disk."
+ }
+ assert {
+ condition = kubernetes_persistent_volume_claim_v1.home.metadata[0].labels["openshell.ai/sandbox-attachable"] == "true" && kubernetes_persistent_volume_claim_v1.home.metadata[0].labels["openshell.ai/sandbox-attachable-workspace"] == "default"
+ error_message = "OpenShell must be allowed to attach the home disk."
+ }
+ assert {
+ condition = keys(yamldecode(local.policy).network_policies) == ["coder"]
+ error_message = "Fully isolated must allow only Coder."
+ }
+ assert {
+ condition = yamldecode(local.policy).network_policies.coder.endpoints[0].host == "coder.example.com" && yamldecode(local.policy).network_policies.coder.endpoints[0].port == 443
+ error_message = "The policy must allow the access URL host and port."
+ }
+ assert {
+ condition = contains([for b in yamldecode(local.policy).network_policies.coder.binaries : b.path], "/usr/local/bin/coder") && contains([for b in yamldecode(local.policy).network_policies.coder.binaries : b.path], "/opt/claude/**")
+ error_message = "The agent and Claude Code must reach Coder."
+ }
+ assert {
+ condition = contains(yamldecode(local.policy).filesystem_policy.read_only, "/usr") && contains(yamldecode(local.policy).filesystem_policy.read_only, "/etc")
+ error_message = "Landlock must keep /usr and /etc read-only."
+ }
+ assert {
+ condition = !can(jsondecode(local.driver_config).kubernetes.pod) && jsondecode(local.driver_config).kubernetes.containers.agent.volume_mounts[0].mount_path == "/sandbox/home"
+ error_message = "With no runtime, the driver config must set no RuntimeClass and mount the home."
+ }
+ assert {
+ condition = alltrue([
+ kubernetes_pod_v1.launcher[0].spec[0].automount_service_account_token == false,
+ kubernetes_pod_v1.launcher[0].spec[0].security_context[0].run_as_non_root == true,
+ kubernetes_pod_v1.launcher[0].spec[0].container[0].security_context[0].allow_privilege_escalation == false,
+ kubernetes_pod_v1.launcher[0].spec[0].container[0].security_context[0].read_only_root_filesystem == true,
+ kubernetes_pod_v1.launcher[0].spec[0].container[0].security_context[0].capabilities[0].drop[0] == "ALL",
+ ])
+ error_message = "The launcher Pod must be hardened."
+ }
+ assert {
+ condition = one([for e in kubernetes_pod_v1.launcher[0].spec[0].container[0].env : e if e.name == "CODER_AGENT_TOKEN"]).value_from[0].secret_key_ref[0].name == kubernetes_secret_v1.agent_token[0].metadata[0].name
+ error_message = "The agent token must come from the Secret, not the Pod spec."
+ }
+ assert {
+ condition = one([for e in kubernetes_pod_v1.launcher[0].spec[0].container[0].env : e.value if e.name == "SANDBOX_NAME"]) == "coder-0b6b6a5e00004" && length(local.sandbox_name) <= 19
+ error_message = "The sandbox name must fit the 19 character limit of OpenShell."
+ }
+ assert {
+ condition = one([for e in kubernetes_pod_v1.launcher[0].spec[0].container[0].env : e.value if e.name == "SANDBOX_CPU"]) == "1" && one([for e in kubernetes_pod_v1.launcher[0].spec[0].container[0].env : e.value if e.name == "SANDBOX_MEMORY"]) == "2Gi"
+ error_message = "The sandbox must get 1 CPU and 2Gi by default."
+ }
+ assert {
+ condition = strcontains(kubernetes_pod_v1.launcher[0].spec[0].container[0].command[2], "openshell sandbox create") && strcontains(kubernetes_pod_v1.launcher[0].spec[0].container[0].command[2], "trap cleanup TERM INT")
+ error_message = "The launcher must create the sandbox and delete it on stop."
+ }
+ assert {
+ condition = endswith(local.agent_command, "exec /usr/local/bin/coder agent") && startswith(local.agent_command, "[ -e /sandbox/home/.bashrc ] || cp -an /etc/skel/.")
+ error_message = "The sandbox must seed the home and run the agent from the image."
+ }
+ assert {
+ condition = jsonencode(kubernetes_pod_v1.launcher[0].metadata[0].labels) == jsonencode(local.labels) && !strcontains(jsonencode(local.labels), "owner") && !strcontains(jsonencode(local.labels), "test")
+ error_message = "The launcher must not depend on the owner or the workspace name, so a claim does not replace it."
+ }
+}
+
+run "github" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "github" }
+ }
+
+ assert {
+ condition = [for e in yamldecode(local.policy).network_policies.profile.endpoints : e.host] == local.github_hosts && alltrue([for e in yamldecode(local.policy).network_policies.profile.endpoints : e.port == 443])
+ error_message = "GitHub only must allow the GitHub hosts on 443."
+ }
+ assert {
+ condition = yamldecode(local.policy).network_policies.profile.binaries[0].path == "/**"
+ error_message = "Profile hosts must be open to every program."
+ }
+ assert {
+ condition = length(module.git-clone) == 0
+ error_message = "With no git_repo_url, nothing is cloned."
+ }
+}
+
+run "development" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "development" }
+ }
+
+ assert {
+ condition = alltrue([for h in ["registry.npmjs.org", "pypi.org", "proxy.golang.org", "github.com"] : contains([for e in yamldecode(local.policy).network_policies.profile.endpoints : e.host], h)])
+ error_message = "GitHub and package registries must allow GitHub, npm, PyPI, and Go."
+ }
+}
+
+run "custom" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "custom" }
+ }
+ override_data {
+ target = data.coder_parameter.custom_hosts
+ values = { value = "[\"Example.com\", \"**.wikipedia.org\"]" }
+ }
+ override_data {
+ target = data.coder_parameter.custom_ports
+ values = { value = "[\"443\", \"22\", \"22\"]" }
+ }
+
+ assert {
+ condition = [for e in yamldecode(local.policy).network_policies.profile.endpoints : "${e.host}:${e.port}"] == ["example.com:443", "example.com:22", "**.wikipedia.org:443", "**.wikipedia.org:22"]
+ error_message = "Custom must allow each typed host on each port once, in lowercase."
+ }
+ assert {
+ condition = keys(yamldecode(local.policy).network_policies) == ["coder", "profile"]
+ error_message = "Custom must keep the Coder rule."
+ }
+ assert {
+ condition = length(data.coder_parameter.custom_problems) == 0 && !contains(keys(data.coder_workspace_preset.profile), "custom")
+ error_message = "Valid Custom fields must show no problems, and Custom has no preset."
+ }
+}
+
+run "custom_rejects_ip_and_cluster_names" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "custom" }
+ }
+ override_data {
+ target = data.coder_parameter.custom_hosts
+ values = { value = "[\"1.2.3.4\", \"kubernetes.default.svc\", \"*.com\"]" }
+ }
+ override_data {
+ target = data.coder_parameter.custom_ports
+ values = { value = "[\"443\"]" }
+ }
+
+ expect_failures = [terraform_data.custom_check]
+}
+
+run "custom_needs_a_port" {
+ command = plan
+
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "custom" }
+ }
+ override_data {
+ target = data.coder_parameter.custom_hosts
+ values = { value = "[\"example.com\"]" }
+ }
+ override_data {
+ target = data.coder_parameter.custom_ports
+ values = { value = "[]" }
+ }
+
+ expect_failures = [terraform_data.custom_check]
+}
+
+run "git_clone" {
+ command = plan
+
+ variables {
+ git_repo_url = "https://github.com/coder/coder"
+ }
+ override_data {
+ target = data.coder_parameter.network_isolation
+ values = { value = "development" }
+ }
+
+ assert {
+ condition = length(module.git-clone) == 1
+ error_message = "GitHub and package registries must clone git_repo_url."
+ }
+}
+
+run "git_clone_skips_fully_isolated" {
+ command = plan
+
+ variables {
+ git_repo_url = "https://github.com/coder/coder"
+ }
+
+ assert {
+ condition = length(module.git-clone) == 0
+ error_message = "Fully isolated cannot reach GitHub, so it must not clone."
+ }
+}
+
+run "stop_keeps_home" {
+ command = plan
+
+ override_data {
+ target = data.coder_workspace.me
+ values = {
+ id = "0b6b6a5e-0000-4000-8000-000000000001"
+ name = "test"
+ start_count = 0
+ transition = "stop"
+ access_url = "https://coder.example.com"
+ }
+ }
+
+ assert {
+ condition = length(kubernetes_pod_v1.launcher) == 0 && length(kubernetes_secret_v1.agent_token) == 0 && kubernetes_persistent_volume_claim_v1.home.metadata[0].name == "coder-0b6b6a5e-0000-4000-8000-000000000001-home"
+ error_message = "Stop must delete the launcher and the token, and keep the home disk."
+ }
+}
+
+run "kata" {
+ command = plan
+
+ variables {
+ runtimes = "kata-qemu, kata-clh"
+ }
+ override_data {
+ target = data.coder_parameter.runtime
+ values = { value = "kata-clh" }
+ }
+
+ assert {
+ condition = [for o in data.coder_parameter.runtime[0].option : o.value] == ["none", "kata-qemu", "kata-clh"]
+ error_message = "Kernel isolation must offer None and then the listed runtimes."
+ }
+ assert {
+ condition = jsondecode(local.driver_config).kubernetes.pod.runtime_class_name == "kata-clh"
+ error_message = "The runtime parameter must set the RuntimeClass of the sandbox Pod."
+ }
+ assert {
+ condition = alltrue([for p in data.coder_workspace_preset.profile : p.parameters.runtime == "none"])
+ error_message = "Presets must set the runtime, so a claim does not change an immutable parameter."
+ }
+}
+
+run "no_runtimes" {
+ command = plan
+
+ assert {
+ condition = length(data.coder_parameter.runtime) == 0 && alltrue([for p in data.coder_workspace_preset.profile : !contains(keys(p.parameters), "runtime")])
+ error_message = "With no runtimes, Kernel isolation must not show, and presets must not set it."
+ }
+}
+
+run "rejects_gvisor" {
+ command = plan
+
+ variables {
+ runtimes = "gvisor"
+ }
+
+ expect_failures = [var.runtimes]
+}
+
+run "presets" {
+ command = plan
+
+ assert {
+ condition = keys(data.coder_workspace_preset.profile) == ["development", "github", "isolated"]
+ error_message = "Every fixed profile must have a preset."
+ }
+ assert {
+ condition = data.coder_workspace_preset.profile["isolated"].default == true && alltrue([for p in data.coder_workspace_preset.profile : one(p.prebuilds).instances == 1])
+ error_message = "Fully isolated must be the default preset, and each preset must have one prebuild."
+ }
+}
+
+run "no_prebuilds" {
+ command = plan
+
+ variables {
+ prebuild_instances = 0
+ }
+
+ assert {
+ condition = alltrue([for p in data.coder_workspace_preset.profile : length(p.prebuilds) == 0])
+ error_message = "prebuild_instances = 0 must turn prebuilds off."
+ }
+}
+
+run "port_from_access_url" {
+ command = plan
+
+ override_data {
+ target = data.coder_workspace.me
+ values = {
+ id = "0b6b6a5e-0000-4000-8000-000000000001"
+ name = "test"
+ start_count = 1
+ transition = "start"
+ access_url = "http://203.0.113.10:8080"
+ }
+ }
+
+ assert {
+ condition = yamldecode(local.policy).network_policies.coder.endpoints[0].host == "203.0.113.10" && yamldecode(local.policy).network_policies.coder.endpoints[0].port == 8080
+ error_message = "The Coder rule must use the access URL port."
+ }
+}
+
+run "ai_gateway_scoped_token" {
+ command = apply
+
+ assert {
+ condition = nonsensitive(coder_env.anthropic_auth_token[0].value) == "scoped-id-scoped-secret" && coder_env.anthropic_base_url[0].value == "https://coder.example.com/api/v2/aibridge/anthropic"
+ error_message = "Claude Code must get the scoped token and the AI Gateway URL, not the owner's session token."
+ }
+ assert {
+ condition = jsonencode(jsondecode(data.http.ai_gateway_token[0].request_body).scopes) == jsonencode(["user:read_personal"]) && nonsensitive(data.http.ai_gateway_token[0].request_headers["Coder-Session-Token"]) == "owner-full-session-token"
+ error_message = "The build must create a token with only the user:read_personal scope, using the owner's session token."
+ }
+}
+
+run "ai_gateway_prebuild" {
+ command = plan
+
+ override_data {
+ target = data.coder_workspace.me
+ values = {
+ id = "0b6b6a5e-0000-4000-8000-000000000001"
+ name = "test"
+ start_count = 1
+ transition = "start"
+ access_url = "https://coder.example.com"
+ is_prebuild = true
+ }
+ }
+
+ assert {
+ condition = length(data.http.ai_gateway_token) == 0 && length(coder_env.anthropic_auth_token) == 0
+ error_message = "A prebuild must not create a token. The claim does."
+ }
+}
+
+run "ai_gateway_off" {
+ command = plan
+
+ variables {
+ enable_ai_gateway = false
+ }
+
+ assert {
+ condition = length(data.http.ai_gateway_token) == 0 && length(coder_env.anthropic_auth_token) == 0 && length(coder_env.anthropic_base_url) == 0
+ error_message = "enable_ai_gateway = false must not create a token."
+ }
+}
+
+run "claude_code_off" {
+ command = plan
+
+ variables {
+ enable_claude_code = false
+ }
+
+ assert {
+ condition = length(coder_app.claude) == 0 && length(data.http.ai_gateway_token) == 0
+ error_message = "enable_claude_code = false must remove the app and the token."
+ }
+}
+
+run "rejects_latest_image" {
+ command = plan
+
+ variables {
+ workspace_image = "registry.example.com/coder/workspace:latest"
+ }
+
+ expect_failures = [var.workspace_image]
+}
+
+run "rejects_untagged_launcher_image" {
+ command = plan
+
+ variables {
+ launcher_image = "registry.example.com/coder/openshell-launcher"
+ }
+
+ expect_failures = [var.launcher_image]
+}
+
+run "rejects_bad_cpu" {
+ command = plan
+
+ variables {
+ cpu = "two"
+ }
+
+ expect_failures = [var.cpu]
+}
+
+run "rejects_bad_memory" {
+ command = plan
+
+ variables {
+ memory = "2G"
+ }
+
+ expect_failures = [var.memory]
+}
+
+run "rejects_empty_namespace" {
+ command = plan
+
+ variables {
+ namespace = " "
+ }
+
+ expect_failures = [var.namespace]
+}