mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
chore: move docker-chat-sandbox under templates/x (#23777)
Adds the experimental `docker-chat-sandbox` example template under `examples/templates/x/`. It provisions a regular dev agent plus a chat-designated agent that runs inside bubblewrap with a read-only root, writable `/home/coder`, and outbound TCP restricted to the Coder control-plane endpoint via `iptables`. The chat agent still appears in dashboard and API responses, but the template reserves it for chatd-managed sessions rather than normal user interaction. `lint/examples` now walks nested template directories, so experimental templates can live under `examples/templates/x/` without treating `x/` itself as a template.
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
# Experimental templates
|
||||
|
||||
Templates in this directory are experimental and may change or be removed without notice.
|
||||
|
||||
They are useful for validating new or unstable Coder behaviors before we commit to them as stable example templates.
|
||||
@@ -0,0 +1,20 @@
|
||||
FROM codercom/enterprise-base:ubuntu
|
||||
|
||||
USER root
|
||||
|
||||
# Install bubblewrap and iptables for sandboxed agent execution.
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends bubblewrap iptables && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Wrapper script that starts the agent inside a bwrap sandbox.
|
||||
# Everything the agent spawns (tool calls, SSH, etc.) inherits
|
||||
# the restricted namespace.
|
||||
COPY bwrap-agent.sh /usr/local/bin/bwrap-agent
|
||||
RUN chmod 755 /usr/local/bin/bwrap-agent
|
||||
|
||||
# Run as root so bwrap can create mount namespaces without needing
|
||||
# user namespace support (which Docker blocks). The bwrap sandbox
|
||||
# itself provides filesystem isolation (read-only root).
|
||||
# The coder user home is still /home/coder (writable via bind mount).
|
||||
ENV HOME=/home/coder
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
display_name: Docker + Chat Sandbox
|
||||
description: Two-agent Docker template with a bubblewrap-sandboxed chat agent
|
||||
icon: ../../../../site/static/icon/docker.png
|
||||
maintainer_github: coder
|
||||
tags: [docker, container, chat]
|
||||
---
|
||||
|
||||
> **Experimental**: This template depends on the `-coderd-chat` agent
|
||||
> naming convention, which is an internal PoC mechanism subject to
|
||||
> change. Do not rely on this for production workloads.
|
||||
|
||||
# Docker + Chat Sandbox
|
||||
|
||||
This template provisions a workspace with two agents:
|
||||
|
||||
| Agent | Purpose | Visible in UI |
|
||||
|-------------------|---------------------------------------------------|---------------|
|
||||
| `dev` | Regular development agent with code-server | Yes |
|
||||
| `dev-coderd-chat` | AI chat agent running inside a bubblewrap sandbox | Yes |
|
||||
|
||||
## How it works
|
||||
|
||||
The `dev` agent is a standard workspace agent with code-server and
|
||||
full filesystem access. Users interact with it normally through the
|
||||
dashboard, SSH, and Coder Connect.
|
||||
|
||||
The `dev-coderd-chat` agent is designated for AI chat sessions via the
|
||||
`-coderd-chat` naming suffix. Chatd routes chat traffic to this agent
|
||||
automatically. The dashboard and REST API still expose it like any other
|
||||
agent, but this template treats it as a chatd-managed sandbox rather
|
||||
than a normal user interaction surface.
|
||||
|
||||
## Bubblewrap sandbox
|
||||
|
||||
The chat agent's init script is wrapped with
|
||||
[bubblewrap](https://github.com/containers/bubblewrap) so the **entire
|
||||
agent process** runs inside a restricted mount namespace with **all
|
||||
capabilities dropped**. Every child process the agent spawns (tool calls
|
||||
via `sh -c`, SSH sessions) inherits the same restrictions.
|
||||
|
||||
The Coder agent hardcodes `sh -c` for tool call execution and ignores
|
||||
the `SHELL` environment variable, so wrapping only the shell would be
|
||||
ineffective. Wrapping the agent binary means the `/bin/bash`, `python3`,
|
||||
or any other binary the model invokes is the one inside the read-only
|
||||
namespace.
|
||||
|
||||
### Sandbox policy
|
||||
|
||||
- **Read-only root filesystem**: cannot install packages, modify system
|
||||
config, or tamper with binaries. Enforced by the kernel mount
|
||||
namespace, applies even to the root user.
|
||||
- **Read-write /home/coder**: project files are editable (shared with
|
||||
the dev agent via a Docker volume).
|
||||
- **Read-write /tmp**: scratch space (the agent binary downloads here
|
||||
during startup, tool calls can use it).
|
||||
- **Shared /proc and /dev**: bind-mounted from the container so CLI
|
||||
tools and the agent work normally.
|
||||
- **Outbound TCP allowlist**: before entering bwrap, the wrapper
|
||||
installs `iptables` and `ip6tables` OUTPUT rules that allow loopback,
|
||||
`ESTABLISHED,RELATED`, and new TCP connections only to the
|
||||
control-plane host and port used by the agent. All other outbound TCP
|
||||
is rejected over both IPv4 and IPv6.
|
||||
- **Near-zero capabilities**: bwrap drops all Linux capabilities
|
||||
except `CAP_DAC_OVERRIDE` before exec'ing the agent. This prevents
|
||||
mount escape (`mount --bind`), ptrace, raw network access, and all
|
||||
other privileged operations. `DAC_OVERRIDE` is retained so the
|
||||
sandbox process (root) can read/write files owned by uid 1000
|
||||
(coder) on the shared home volume without changing ownership.
|
||||
|
||||
### How the capability lifecycle works
|
||||
|
||||
1. Docker starts the container as root with `CAP_SYS_ADMIN`,
|
||||
`CAP_NET_ADMIN`, and `CAP_DAC_OVERRIDE`.
|
||||
2. The entrypoint runs `bwrap-agent`, which resolves the control-plane
|
||||
host and installs the outbound TCP allowlist with `iptables` and
|
||||
`ip6tables`.
|
||||
3. bwrap creates the mount namespace using `CAP_SYS_ADMIN`.
|
||||
4. bwrap drops all capabilities except `DAC_OVERRIDE`.
|
||||
5. bwrap exec's the agent binary with only `DAC_OVERRIDE`.
|
||||
6. All tool calls spawned by the agent inherit only `DAC_OVERRIDE`.
|
||||
|
||||
After step 4, the process cannot remount filesystems, change ownership,
|
||||
ptrace other processes, or perform any other privileged operation. It
|
||||
can read and write files regardless of Unix permissions, which is needed
|
||||
because the shared home volume is owned by uid 1000 (coder) but the
|
||||
sandbox runs as root.
|
||||
|
||||
### Limitations
|
||||
|
||||
- **No PID namespace isolation**: Docker's namespace setup conflicts
|
||||
with nested PID namespaces (`--unshare-pid`). Processes inside the
|
||||
sandbox can see other container processes via `/proc`.
|
||||
- **No user namespace isolation**: Docker blocks nested user namespaces.
|
||||
The container runs as root uid 0, but with zero capabilities the
|
||||
effective privilege level is lower than an unprivileged user.
|
||||
- **Only outbound TCP is filtered**: UDP, ICMP, and inbound traffic
|
||||
still follow Docker's normal container networking rules. DNS usually
|
||||
continues to work over UDP, but DNS-over-TCP is blocked unless it uses
|
||||
the control-plane endpoint.
|
||||
- **IP resolution at startup**: the outbound allowlist resolves the
|
||||
control-plane hostname once with `getent ahostsv4` and, when IPv6 is
|
||||
enabled, `getent ahostsv6`. If those lookups fail, or if the endpoint
|
||||
later moves to a different IP, the chat container must restart to
|
||||
refresh the rules.
|
||||
- **seccomp=unconfined**: Docker's default seccomp profile blocks
|
||||
`pivot_root`, which bwrap needs. A custom seccomp profile that allows
|
||||
only `pivot_root` and `mount` would be more restrictive.
|
||||
|
||||
Template authors can adjust the sandbox policy in `bwrap-agent.sh` by
|
||||
adding `--bind` flags for additional writable paths.
|
||||
|
||||
## Usage
|
||||
|
||||
After starting `./scripts/develop.sh`, push this template:
|
||||
|
||||
```bash
|
||||
cd examples/templates/x/docker-chat-sandbox
|
||||
coder templates push docker-chat-sandbox \
|
||||
--var docker_socket="$(docker context inspect --format '{{ .Endpoints.docker.Host }}')"
|
||||
```
|
||||
|
||||
Then create a workspace from it and start a chat session.
|
||||
@@ -0,0 +1,190 @@
|
||||
#!/bin/bash
|
||||
# bwrap-agent.sh: Start the Coder agent inside a bubblewrap sandbox.
|
||||
#
|
||||
# This script wraps the agent binary and all its children in a bwrap
|
||||
# mount namespace with almost all capabilities dropped.
|
||||
#
|
||||
# Sandbox policy:
|
||||
# - Root filesystem is read-only (prevents system modification)
|
||||
# - /home/coder is read-write (project files, shared with dev agent)
|
||||
# - /tmp is read-write (scratch space, bind from container /tmp)
|
||||
# - /proc is bind-mounted from host (needed by CLI tools)
|
||||
# - /dev is bind-mounted from host (devices)
|
||||
# - Outbound TCP is restricted to the control-plane endpoint
|
||||
# over IPv4 and IPv6.
|
||||
# - All capabilities dropped except DAC_OVERRIDE.
|
||||
#
|
||||
# DAC_OVERRIDE is retained so the sandbox process (running as root)
|
||||
# can read and write files owned by uid 1000 (coder) on the shared
|
||||
# home volume without chowning them. This preserves correct
|
||||
# ownership for the dev agent, which runs as the coder user.
|
||||
#
|
||||
# The container must run as root with CAP_SYS_ADMIN and CAP_NET_ADMIN
|
||||
# so bwrap can create the mount namespace and this wrapper can install
|
||||
# iptables/ip6tables rules. bwrap then drops all caps except
|
||||
# DAC_OVERRIDE before exec'ing the child process.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
fail() {
|
||||
echo "bwrap-agent: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
discover_control_plane_url() {
|
||||
if [ -n "${CODER_SANDBOX_CONTROL_PLANE_URL:-}" ]; then
|
||||
printf '%s\n' "$CODER_SANDBOX_CONTROL_PLANE_URL"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local arg url
|
||||
for arg in "$@"; do
|
||||
if [ -f "$arg" ]; then
|
||||
url=$(grep -aoE "https?://[^\"'[:space:]]+" "$arg" | head -n1 || true)
|
||||
if [ -n "$url" ]; then
|
||||
printf '%s\n' "$url"
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
parse_control_plane_host_port() {
|
||||
local url="$1"
|
||||
local host_port host port
|
||||
|
||||
host_port="${url#*://}"
|
||||
host_port="${host_port%%/*}"
|
||||
if [ -z "$host_port" ]; then
|
||||
fail "control-plane URL is missing a host: $url"
|
||||
fi
|
||||
|
||||
case "$host_port" in
|
||||
\[*\]:*)
|
||||
host="${host_port#\[}"
|
||||
host="${host%%\]*}"
|
||||
port="${host_port##*:}"
|
||||
;;
|
||||
\[*\])
|
||||
host="${host_port#\[}"
|
||||
host="${host%\]}"
|
||||
case "$url" in
|
||||
https://*) port=443 ;;
|
||||
http://*) port=80 ;;
|
||||
*) fail "unsupported control-plane URL scheme: $url" ;;
|
||||
esac
|
||||
;;
|
||||
*:*:*)
|
||||
fail "IPv6 control-plane URLs must use brackets: $url"
|
||||
;;
|
||||
*:*)
|
||||
host="${host_port%%:*}"
|
||||
port="${host_port##*:}"
|
||||
;;
|
||||
*)
|
||||
host="$host_port"
|
||||
case "$url" in
|
||||
https://*) port=443 ;;
|
||||
http://*) port=80 ;;
|
||||
*) fail "unsupported control-plane URL scheme: $url" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ -z "$host" || -z "$port" || ! "$port" =~ ^[0-9]+$ ]]; then
|
||||
fail "failed to parse control-plane host and port from: $url"
|
||||
fi
|
||||
|
||||
printf '%s %s\n' "$host" "$port"
|
||||
}
|
||||
|
||||
ipv6_enabled() {
|
||||
[ -s /proc/net/if_inet6 ]
|
||||
}
|
||||
|
||||
install_family_tcp_egress_rules() {
|
||||
local family="$1"
|
||||
local port="$2"
|
||||
shift 2
|
||||
local -a control_plane_ips=("$@")
|
||||
local chain ip
|
||||
local -a table_cmd
|
||||
|
||||
case "$family" in
|
||||
ipv4)
|
||||
chain="CODER_CHAT_SANDBOX_OUT4"
|
||||
table_cmd=(iptables -w 5)
|
||||
;;
|
||||
ipv6)
|
||||
chain="CODER_CHAT_SANDBOX_OUT6"
|
||||
table_cmd=(ip6tables -w 5)
|
||||
;;
|
||||
*)
|
||||
fail "unsupported IP family: $family"
|
||||
;;
|
||||
esac
|
||||
|
||||
"${table_cmd[@]}" -N "$chain" 2>/dev/null || true
|
||||
"${table_cmd[@]}" -F "$chain"
|
||||
while "${table_cmd[@]}" -C OUTPUT -j "$chain" >/dev/null 2>&1; do
|
||||
"${table_cmd[@]}" -D OUTPUT -j "$chain"
|
||||
done
|
||||
"${table_cmd[@]}" -I OUTPUT 1 -j "$chain"
|
||||
|
||||
"${table_cmd[@]}" -A "$chain" -o lo -j ACCEPT
|
||||
"${table_cmd[@]}" -A "$chain" -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
|
||||
for ip in "${control_plane_ips[@]}"; do
|
||||
[ -n "$ip" ] || continue
|
||||
"${table_cmd[@]}" -A "$chain" -p tcp -d "$ip" --dport "$port" -j ACCEPT
|
||||
done
|
||||
"${table_cmd[@]}" -A "$chain" -p tcp -j REJECT --reject-with tcp-reset
|
||||
"${table_cmd[@]}" -A "$chain" -j RETURN
|
||||
}
|
||||
|
||||
install_tcp_egress_rules() {
|
||||
local url="$1"
|
||||
local host port
|
||||
local -a control_plane_ipv4s=()
|
||||
local -a control_plane_ipv6s=()
|
||||
|
||||
read -r host port < <(parse_control_plane_host_port "$url")
|
||||
mapfile -t control_plane_ipv4s < <(getent ahostsv4 "$host" | awk '{print $1}' | sort -u)
|
||||
if ipv6_enabled; then
|
||||
mapfile -t control_plane_ipv6s < <(getent ahostsv6 "$host" | awk '{print $1}' | sort -u)
|
||||
fi
|
||||
if [ "${#control_plane_ipv4s[@]}" -eq 0 ] && [ "${#control_plane_ipv6s[@]}" -eq 0 ]; then
|
||||
fail "failed to resolve control-plane host: $host"
|
||||
fi
|
||||
|
||||
install_family_tcp_egress_rules ipv4 "$port" "${control_plane_ipv4s[@]}"
|
||||
if ipv6_enabled; then
|
||||
install_family_tcp_egress_rules ipv6 "$port" "${control_plane_ipv6s[@]}"
|
||||
fi
|
||||
}
|
||||
|
||||
command -v bwrap >/dev/null 2>&1 || fail "bubblewrap not found"
|
||||
command -v getent >/dev/null 2>&1 || fail "getent not found"
|
||||
command -v iptables >/dev/null 2>&1 || fail "iptables not found"
|
||||
if ipv6_enabled; then
|
||||
command -v ip6tables >/dev/null 2>&1 || fail "ip6tables not found"
|
||||
fi
|
||||
|
||||
control_plane_url=$(discover_control_plane_url "$@" || true)
|
||||
if [ -z "$control_plane_url" ]; then
|
||||
fail "failed to determine control-plane URL"
|
||||
fi
|
||||
|
||||
install_tcp_egress_rules "$control_plane_url"
|
||||
|
||||
exec bwrap \
|
||||
--ro-bind / / \
|
||||
--bind /home/coder /home/coder \
|
||||
--bind /tmp /tmp \
|
||||
--bind /proc /proc \
|
||||
--dev-bind /dev /dev \
|
||||
--die-with-parent \
|
||||
--cap-drop ALL \
|
||||
--cap-add cap_dac_override \
|
||||
"$@"
|
||||
@@ -0,0 +1,298 @@
|
||||
terraform {
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
}
|
||||
docker = {
|
||||
source = "kreuzwerker/docker"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
locals {
|
||||
username = data.coder_workspace_owner.me.name
|
||||
chat_control_plane_url = replace(data.coder_workspace.me.access_url, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")
|
||||
}
|
||||
|
||||
variable "docker_socket" {
|
||||
default = ""
|
||||
description = "(Optional) Docker socket URI"
|
||||
type = string
|
||||
}
|
||||
|
||||
provider "docker" {
|
||||
host = var.docker_socket != "" ? var.docker_socket : null
|
||||
}
|
||||
|
||||
data "coder_provisioner" "me" {}
|
||||
data "coder_workspace" "me" {}
|
||||
data "coder_workspace_owner" "me" {}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Agent 1: Regular dev agent (user-facing, appears in the dashboard)
|
||||
# -------------------------------------------------------------------
|
||||
resource "coder_agent" "dev" {
|
||||
arch = data.coder_provisioner.me.arch
|
||||
os = "linux"
|
||||
startup_script = <<-EOT
|
||||
set -e
|
||||
if [ ! -f ~/.init_done ]; then
|
||||
cp -rT /etc/skel ~
|
||||
touch ~/.init_done
|
||||
fi
|
||||
EOT
|
||||
|
||||
env = {
|
||||
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "CPU Usage"
|
||||
key = "0_cpu_usage"
|
||||
script = "coder stat cpu"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "RAM Usage"
|
||||
key = "1_ram_usage"
|
||||
script = "coder stat mem"
|
||||
interval = 10
|
||||
timeout = 1
|
||||
}
|
||||
|
||||
metadata {
|
||||
display_name = "Home Disk"
|
||||
key = "3_home_disk"
|
||||
script = "coder stat disk --path $${HOME}"
|
||||
interval = 60
|
||||
timeout = 1
|
||||
}
|
||||
}
|
||||
|
||||
# See https://registry.coder.com/modules/coder/code-server
|
||||
module "code-server" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
source = "registry.coder.com/coder/code-server/coder"
|
||||
version = "~> 1.0"
|
||||
agent_id = coder_agent.dev.id
|
||||
order = 1
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Agent 2: Chat agent (designated for chatd-managed AI chat)
|
||||
#
|
||||
# This agent runs inside a bubblewrap (bwrap) sandbox. The entire
|
||||
# agent process and all its children (tool calls, SSH sessions, etc.)
|
||||
# execute in a restricted mount namespace. There is no escape path
|
||||
# because the sandbox wraps the agent binary itself, not just the
|
||||
# shell.
|
||||
#
|
||||
# The agent name "dev-coderd-chat" ends with the -coderd-chat suffix
|
||||
# that tells chatd to route chats here. The dashboard still shows the
|
||||
# agent, but the template reserves it for chatd-managed sessions rather
|
||||
# than normal user interaction.
|
||||
#
|
||||
# NOTE: Terraform resource labels cannot contain hyphens, but the
|
||||
# Coder provisioner uses the label as the agent name (and rejects
|
||||
# underscores). To work around this, the resource label uses hyphens
|
||||
# and all references go through the local.chat_agent indirection
|
||||
# below.
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
# Terraform parses "coder_agent.dev-coderd-chat.X" as subtraction,
|
||||
# so we capture the agent attributes in locals for clean references.
|
||||
locals {
|
||||
# The resource block below uses a hyphenated label so the Coder
|
||||
# provisioner registers the agent name as "dev-coderd-chat".
|
||||
# These locals let the rest of the config reference its attributes
|
||||
# without Terraform misinterpreting the hyphens.
|
||||
chat_agent_init = replace(coder_agent.dev-coderd-chat.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")
|
||||
chat_agent_token = coder_agent.dev-coderd-chat.token
|
||||
}
|
||||
|
||||
resource "coder_agent" "dev-coderd-chat" {
|
||||
arch = data.coder_provisioner.me.arch
|
||||
os = "linux"
|
||||
order = 99
|
||||
startup_script = <<-EOT
|
||||
set -e
|
||||
if [ ! -f ~/.init_done ]; then
|
||||
cp -rT /etc/skel ~
|
||||
touch ~/.init_done
|
||||
fi
|
||||
EOT
|
||||
|
||||
env = {
|
||||
GIT_AUTHOR_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_AUTHOR_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
GIT_COMMITTER_NAME = coalesce(data.coder_workspace_owner.me.full_name, data.coder_workspace_owner.me.name)
|
||||
GIT_COMMITTER_EMAIL = "${data.coder_workspace_owner.me.email}"
|
||||
}
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Docker image with bubblewrap pre-installed
|
||||
# -------------------------------------------------------------------
|
||||
resource "docker_image" "chat_sandbox" {
|
||||
name = "coder-chat-sandbox:latest"
|
||||
|
||||
build {
|
||||
context = "."
|
||||
dockerfile = "Dockerfile.chat"
|
||||
}
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Shared home volume
|
||||
# -------------------------------------------------------------------
|
||||
resource "docker_volume" "home_volume" {
|
||||
name = "coder-${data.coder_workspace.me.id}-home"
|
||||
lifecycle {
|
||||
ignore_changes = all
|
||||
}
|
||||
labels {
|
||||
label = "coder.owner"
|
||||
value = data.coder_workspace_owner.me.name
|
||||
}
|
||||
labels {
|
||||
label = "coder.owner_id"
|
||||
value = data.coder_workspace_owner.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_id"
|
||||
value = data.coder_workspace.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_name_at_creation"
|
||||
value = data.coder_workspace.me.name
|
||||
}
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Container 1: Dev workspace (regular agent, no sandbox)
|
||||
# -------------------------------------------------------------------
|
||||
resource "docker_container" "dev" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
image = "codercom/enterprise-base:ubuntu"
|
||||
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}"
|
||||
hostname = data.coder_workspace.me.name
|
||||
entrypoint = [
|
||||
"sh", "-c",
|
||||
replace(coder_agent.dev.init_script, "/localhost|127\\.0\\.0\\.1/", "host.docker.internal")
|
||||
]
|
||||
env = ["CODER_AGENT_TOKEN=${coder_agent.dev.token}"]
|
||||
|
||||
host {
|
||||
host = "host.docker.internal"
|
||||
ip = "host-gateway"
|
||||
}
|
||||
|
||||
volumes {
|
||||
container_path = "/home/coder"
|
||||
volume_name = docker_volume.home_volume.name
|
||||
read_only = false
|
||||
}
|
||||
|
||||
labels {
|
||||
label = "coder.owner"
|
||||
value = data.coder_workspace_owner.me.name
|
||||
}
|
||||
labels {
|
||||
label = "coder.owner_id"
|
||||
value = data.coder_workspace_owner.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_id"
|
||||
value = data.coder_workspace.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_name"
|
||||
value = data.coder_workspace.me.name
|
||||
}
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# Container 2: Chat sandbox (agent runs inside bubblewrap)
|
||||
#
|
||||
# The entrypoint pipes the agent init script through bwrap-agent,
|
||||
# which starts the entire agent binary inside a bwrap namespace.
|
||||
# Every process the agent spawns (sh -c for tool calls, SSH
|
||||
# sessions, etc.) inherits the restricted mount namespace:
|
||||
#
|
||||
# - Read-only root filesystem (cannot modify system files)
|
||||
# - Read-write /home/coder (shared project files)
|
||||
# - Private /tmp (tmpfs scratch space)
|
||||
# - Shared network namespace with outbound TCP restricted to the
|
||||
# Coder control-plane endpoint used by the agent over IPv4 and IPv6
|
||||
#
|
||||
# Because the agent itself runs inside bwrap, there is no way for
|
||||
# a tool call to escape the sandbox by invoking /bin/bash or any
|
||||
# other binary directly. All binaries are inside the same namespace.
|
||||
# -------------------------------------------------------------------
|
||||
resource "docker_container" "chat" {
|
||||
count = data.coder_workspace.me.start_count
|
||||
image = docker_image.chat_sandbox.image_id
|
||||
name = "coder-${data.coder_workspace_owner.me.name}-${lower(data.coder_workspace.me.name)}-chat"
|
||||
hostname = "${data.coder_workspace.me.name}-chat"
|
||||
|
||||
# Capability budget:
|
||||
# - SYS_ADMIN: bwrap needs this to create mount namespaces.
|
||||
# - NET_ADMIN: the wrapper needs this to install iptables OUTPUT
|
||||
# rules before entering bwrap.
|
||||
# - DAC_OVERRIDE: passed through to the sandbox so the agent
|
||||
# (running as root) can read/write files owned by uid 1000 on
|
||||
# the shared home volume without changing ownership.
|
||||
# - seccomp=unconfined: Docker's default seccomp profile blocks
|
||||
# pivot_root, which bwrap uses during namespace setup.
|
||||
capabilities {
|
||||
add = ["SYS_ADMIN", "NET_ADMIN", "DAC_OVERRIDE"]
|
||||
drop = ["ALL"]
|
||||
}
|
||||
security_opts = ["seccomp=unconfined"]
|
||||
|
||||
# Wrap the init script through bwrap-agent so the agent binary
|
||||
# and all its children run inside the sandbox namespace.
|
||||
# The init script is base64-encoded to avoid nested shell quoting
|
||||
# issues, then decoded and executed at container startup.
|
||||
entrypoint = [
|
||||
"sh", "-c",
|
||||
"echo ${base64encode(local.chat_agent_init)} | base64 -d > /tmp/coder-init.sh && chmod +x /tmp/coder-init.sh && exec bwrap-agent sh /tmp/coder-init.sh"
|
||||
]
|
||||
env = [
|
||||
"CODER_AGENT_TOKEN=${local.chat_agent_token}",
|
||||
"CODER_SANDBOX_CONTROL_PLANE_URL=${local.chat_control_plane_url}",
|
||||
]
|
||||
|
||||
host {
|
||||
host = "host.docker.internal"
|
||||
ip = "host-gateway"
|
||||
}
|
||||
|
||||
volumes {
|
||||
container_path = "/home/coder"
|
||||
volume_name = docker_volume.home_volume.name
|
||||
read_only = false
|
||||
}
|
||||
|
||||
labels {
|
||||
label = "coder.owner"
|
||||
value = data.coder_workspace_owner.me.name
|
||||
}
|
||||
labels {
|
||||
label = "coder.owner_id"
|
||||
value = data.coder_workspace_owner.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_id"
|
||||
value = data.coder_workspace.me.id
|
||||
}
|
||||
labels {
|
||||
label = "coder.workspace_name"
|
||||
value = data.coder_workspace.me.name
|
||||
}
|
||||
}
|
||||
@@ -49,17 +49,25 @@ func run(lint bool) error {
|
||||
|
||||
var paths []string
|
||||
if lint {
|
||||
files, err := fs.ReadDir(examplesFS, "templates")
|
||||
err := fs.WalkDir(examplesFS, "templates", func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if path == "templates" {
|
||||
return nil
|
||||
}
|
||||
if !isTemplateExampleDir(examplesFS, path) {
|
||||
return nil
|
||||
}
|
||||
paths = append(paths, path)
|
||||
return fs.SkipDir
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, f := range files {
|
||||
if !f.IsDir() {
|
||||
continue
|
||||
}
|
||||
paths = append(paths, filepath.Join("templates", f.Name()))
|
||||
}
|
||||
} else {
|
||||
for _, comment := range src.Comments {
|
||||
for _, line := range comment.List {
|
||||
@@ -102,6 +110,18 @@ func run(lint bool) error {
|
||||
return enc.Encode(examples)
|
||||
}
|
||||
|
||||
func isTemplateExampleDir(examplesFS fs.FS, name string) bool {
|
||||
readmePath := path.Join(name, "README.md")
|
||||
mainTFPath := path.Join(name, "main.tf")
|
||||
if _, err := fs.Stat(examplesFS, readmePath); err != nil {
|
||||
return false
|
||||
}
|
||||
if _, err := fs.Stat(examplesFS, mainTFPath); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseTemplateExample(projectFS, examplesFS fs.FS, name string) (te *codersdk.TemplateExample, err error) {
|
||||
var errs []error
|
||||
defer func() {
|
||||
|
||||
Reference in New Issue
Block a user