Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/aidc-e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ jobs:

for f in \
.devcontainer/Dockerfile \
.devcontainer/Dockerfile.base \
.devcontainer/compose.yaml \
.devcontainer/devcontainer.json \
.devcontainer/scripts/bootstrap-state.sh \
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,8 @@ echo "AIDC_ISOLATE_VM=1" >> .ai-container/project.env
## Notes

- Generated files are added to `.git/info/exclude` when the target directory is a git repo, so your project stays clean. The seeded project docs (`CHANGELOG.md`, `DETAILED_CHANGELOG.md`, `logs/`) are *not* excluded — they belong to your repo and are meant to be committed.
- Settings can be set host-wide in `~/.config/aidc/config.env` (universal defaults for every project) or per project in `.ai-container/project.env`, which overrides the global default. Both files are sourced for env vars like `AIDC_AUTO_SYNC_SESSIONS`, `AIDC_ENABLE_EGRESS_FIREWALL`, and `AIDC_ISOLATE_VM`.
- Settings can be set host-wide in `~/.config/aidc/config.env` (universal defaults for every project) or per project in `.ai-container/project.env`, which overrides the global default. Both files are sourced for env vars like `AIDC_AUTO_SYNC_SESSIONS`, `AIDC_ENABLE_EGRESS_FIREWALL`, `AIDC_ISOLATE_VM`, and `AIDC_BASE_IMAGE`.
- **Projects share one `aidc-base` image.** The common layer (base OS, apt tools, uv/Python, agents, scanners) is built once as `aidc-base:<hash>` and reused by every project via `FROM aidc-base` — a fresh project only builds its toolchain delta (minutes, not tens of minutes). `docker images` shows the full logical size per project, but on disk the base layers are shared; N projects cost ~3 GB once + small deltas. Pin a custom base with `AIDC_BASE_IMAGE=<tag>` in `.ai-container/project.env`.
- Container egress is open by default; set `AIDC_ENABLE_EGRESS_FIREWALL=1` in `.ai-container/project.env` for a default-deny allowlist. See [`docs/security.md`](docs/security.md#optional-egress-firewall).
- The host-clipboard bridge is **off by default** — no host clipboard socket is mounted into the container. Opt in per (re)create with `aidc up --clipboard` / `aidc rebuild --clipboard`, or persist `AIDC_ENABLE_CLIPBOARD=1` in `.ai-container/project.env`. See [`docs/clipboard-bridge.md`](docs/clipboard-bridge.md).
- Per-project VM isolation (`--isolate-vm`) is **off by default** due to resource cost. Opt in per (re)create with `aidc up --isolate-vm` / `aidc rebuild --isolate-vm`, or persist `AIDC_ISOLATE_VM=1` in `.ai-container/project.env`. See [Isolation modes](#isolation-modes) above.
Expand Down
49 changes: 49 additions & 0 deletions lib/aidc.sh
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ AIDC_CORE_WORKTREE_ROOT="${AIDC_CORE_WORKTREE_ROOT:-$HOME/.local/share/aidc/core
AIDC_MANAGED_CLAUDE_ALIAS_MARKER="# aidc-managed claude-alias"
AIDC_MANAGED_PATHS=(
".devcontainer/Dockerfile"
".devcontainer/Dockerfile.base"
".devcontainer/compose.yaml"
".devcontainer/devcontainer.json"
".devcontainer/scripts/bootstrap-state.sh"
Expand Down Expand Up @@ -204,6 +205,7 @@ aidc::refresh_scaffold() {
"$workspace/.cursor/rules"

aidc::copy_template "templates/devcontainer/Dockerfile.tmpl" "$workspace/.devcontainer/Dockerfile" "0755"
aidc::copy_template "templates/devcontainer/Dockerfile.base.tmpl" "$workspace/.devcontainer/Dockerfile.base" "0644"
aidc::copy_template "templates/devcontainer/compose.yaml.tmpl" "$workspace/.devcontainer/compose.yaml" "0644"
aidc::copy_template "templates/devcontainer/devcontainer.json.tmpl" "$workspace/.devcontainer/devcontainer.json" "0644"
aidc::copy_template "templates/devcontainer/scripts/bootstrap-state.sh.tmpl" "$workspace/.devcontainer/scripts/bootstrap-state.sh" "0755"
Expand Down Expand Up @@ -237,6 +239,7 @@ aidc::cmd_up() {
aidc::vm_ensure "$workspace"
fi

aidc::ensure_base_image "$workspace"
aidc::compose "$workspace" up -d --build workspace
# Catch up the host with any transcripts a prior (possibly ungraceful) session
# left in the volume — the recovery case the on-exit hooks can't cover.
Expand All @@ -254,6 +257,7 @@ aidc::cmd_rebuild() {
aidc::vm_ensure "$workspace"
fi

aidc::ensure_base_image "$workspace"
aidc::compose "$workspace" up -d --build --force-recreate workspace
aidc::auto_sync_sessions "$workspace" all
aidc::log "container rebuilt for $(basename "$workspace")"
Expand Down Expand Up @@ -282,6 +286,7 @@ aidc::cmd_rescan() {
fi

# --build picks up the changed AIDC_TOOLCHAINS build arg and reinstalls.
aidc::ensure_base_image "$workspace"
aidc::compose "$workspace" up -d --build workspace
aidc::log "rescan complete for $(basename "$workspace")"
}
Expand Down Expand Up @@ -1529,6 +1534,7 @@ aidc::ensure_container_running() {
fi

if [[ -z "$(aidc::compose_capture "$workspace" ps -q workspace)" ]]; then
aidc::ensure_base_image "$workspace"
aidc::compose "$workspace" up -d --build workspace
# Only on the down→up transition (not every exec), so we recover prior
# transcripts without adding a sync to each command.
Expand Down Expand Up @@ -1741,6 +1747,49 @@ aidc::compose() {
docker compose -f "$workspace/.devcontainer/compose.yaml" "$@"
}

# Shared base image tag. Content-hashed from the workspace's Dockerfile.base
# plus the requested agent set, so any template edit — or a different
# AIDC_AGENTS selection — flows into a new tag automatically (and projects
# that share the same template + agent set share the same base image on disk).
aidc::base_image_tag() {
local workspace="$1"
local hash cmd input
if command -v sha256sum >/dev/null 2>&1; then
cmd="sha256sum"
else
cmd="shasum -a 256"
fi
input="$(cat "$workspace/.devcontainer/Dockerfile.base" 2>/dev/null)"
input+="|AIDC_AGENTS=${AIDC_AGENTS:-all}"
hash="$(printf '%s' "$input" | $cmd | awk '{print $1}' | cut -c1-12)"
printf 'aidc-base:%s' "${hash:-latest}"
}

# Ensure the shared aidc-base image exists locally, building it once per
# content hash. Returns the tag via AIDC_BASE_IMAGE (exported) so the
# per-project Dockerfile's FROM resolves. Called before any compose build so
# a fresh project only pays for the toolchain delta, not the ~3GB common
# layer. Set AIDC_BASE_IMAGE explicitly in project.env to pin a custom base.
aidc::ensure_base_image() {
local workspace="$1"
local tag
if [[ -n "${AIDC_BASE_IMAGE:-}" ]]; then
tag="$AIDC_BASE_IMAGE"
else
tag="$(aidc::base_image_tag "$workspace")"
export AIDC_BASE_IMAGE="$tag"
fi
if ! docker image inspect "$tag" >/dev/null 2>&1; then
aidc::log "building shared base image $tag (one-time; shared across projects)"
( cd "$workspace" \
&& docker build -f "$workspace/.devcontainer/Dockerfile.base" \
--build-arg AIDC_AGENTS="${AIDC_AGENTS:-all}" \
-t "$tag" "$workspace/.devcontainer" ) \
|| aidc::die "failed to build base image $tag"
aidc::log "base image $tag ready"
fi
}

aidc::compose_capture() {
local workspace="$1"
shift
Expand Down
226 changes: 226 additions & 0 deletions templates/devcontainer/Dockerfile.base.tmpl
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
# aidc-managed
# Shared base image: base OS + apt tools + uv/Python + coding agents +
# always-on scanners + clipboard bridge + pmg wiring.
#
# Built ONCE per content hash (see aidc::ensure_base_image in lib/aidc.sh)
# and reused by every project via `FROM aidc-base:<hash>` in the per-project
# Dockerfile. This is the "one copy of Python serves 10,000 sandboxes" layer:
# projects share the big common layers on disk and only build their small
# toolchain delta on top. To force a refresh of unpinned agent/scanner
# installs, bump a pinned version here (the tag changes, everything rebuilds).
#
# Base image and uv stage pinned by index (multi-arch) digest.
# To repin: `docker pull <ref>:<tag>` then read the "Digest:" line.
FROM ghcr.io/astral-sh/uv:0.10@sha256:72ab0aeb448090480ccabb99fb5f52b0dc3c71923bffb5e2e26517a1c27b7fec AS uv
FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04@sha256:d94c97dd9cacf183d0a6fd12a8e87b526e9e928307674ae9c94139139c0c6eae

ARG GIT_DELTA_VERSION=0.18.2

ENV DEBIAN_FRONTEND=noninteractive

USER root

RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bubblewrap \
build-essential \
ca-certificates \
curl \
dnsutils \
fd-find \
fzf \
gh \
gnupg \
ipset \
iptables \
iproute2 \
jq \
nano \
ripgrep \
rsync \
socat \
tmux \
unzip \
vim \
xz-utils \
zip \
zsh \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_22.x nodistro main" > /etc/apt/sources.list.d/nodesource.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends nodejs \
&& ln -sf /usr/bin/fdfind /usr/local/bin/fd \
&& rm -rf /var/lib/apt/lists/*

RUN ARCH=$(dpkg --print-architecture) \
&& curl -fsSL "https://github.com/dandavison/delta/releases/download/${GIT_DELTA_VERSION}/git-delta_${GIT_DELTA_VERSION}_${ARCH}.deb" -o /tmp/git-delta.deb \
&& dpkg -i /tmp/git-delta.deb \
&& rm /tmp/git-delta.deb

COPY --from=uv /uv /usr/local/bin/uv
ENV UV_PYTHON_INSTALL_DIR=/opt/uv/python \
UV_TOOL_DIR=/opt/uv/tools \
UV_TOOL_BIN_DIR=/usr/local/bin \
UV_LINK_MODE=copy
RUN uv python install 3.13 \
&& ln -sf "$(uv python find 3.13)" /usr/local/bin/python3 \
&& ln -sf /usr/local/bin/python3 /usr/local/bin/python \
&& uv tool install ast-grep-cli

ENV NPM_CONFIG_IGNORE_SCRIPTS=true \
NPM_CONFIG_AUDIT=true \
NPM_CONFIG_FUND=false \
NPM_CONFIG_SAVE_EXACT=true \
NPM_CONFIG_UPDATE_NOTIFIER=false \
NPM_CONFIG_MINIMUM_RELEASE_AGE=1440 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
NODE_OPTIONS=--max-old-space-size=4096

# Coding agents are installed as native prebuilt binaries per-user below (no
# npm-global, no Node runtime dependency for the agents themselves). The
# NPM_CONFIG_* hardening above still governs any npm the agents or project
# toolchains invoke at runtime.

# Supply-chain guardrails: pmg intercepts npm/pip/uv/etc. and runs vet checks
# before installs. Binaries installed system-wide; shell/shim wiring done
# per-user below.
RUN curl -fsSL https://raw.githubusercontent.com/safedep/pmg/main/install.sh | sh

# Pinned. To bump: check https://github.com/safedep/vet/releases for the
# newest tag, update VET_VERSION, rebuild, and commit. "latest" is honoured
# for ad-hoc overrides but resolves at build time, which defeats the pin —
# avoid in committed configs.
ARG VET_VERSION=v1.17.3
RUN ARCH_RAW="$(dpkg --print-architecture)" \
&& case "$ARCH_RAW" in \
amd64) VET_ARCH="x86_64" ;; \
arm64) VET_ARCH="arm64" ;; \
*) echo "unsupported arch: $ARCH_RAW" >&2; exit 1 ;; \
esac \
&& if [ "$VET_VERSION" = "latest" ]; then \
VET_TAG="$(curl -fsSI -o /dev/null -w '%{redirect_url}' https://github.com/safedep/vet/releases/latest | sed 's|.*/||')"; \
else \
VET_TAG="$VET_VERSION"; \
fi \
&& curl -fsSL "https://github.com/safedep/vet/releases/download/${VET_TAG}/vet_Linux_${VET_ARCH}.tar.gz" -o /tmp/vet.tar.gz \
&& tar -xzf /tmp/vet.tar.gz -C /usr/local/bin vet \
&& chmod 0755 /usr/local/bin/vet \
&& rm /tmp/vet.tar.gz

# Always-on security tooling: semgrep (SAST), gitleaks + trufflehog (secrets).
# SCA is handled by vet/pmg above; this layer covers code-pattern and secret scans.
RUN uv tool install semgrep \
&& curl -fsSL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh \
| sh -s -- -b /usr/local/bin

# Pinned. To bump: check https://github.com/gitleaks/gitleaks/releases for the
# newest tag, update GITLEAKS_VERSION, rebuild, and commit. "latest" still
# works for ad-hoc overrides but resolves at build time and breaks the pin.
ARG GITLEAKS_VERSION=v8.30.1
RUN ARCH_RAW="$(dpkg --print-architecture)" \
&& case "$ARCH_RAW" in \
amd64) GL_ARCH="x64" ;; \
arm64) GL_ARCH="arm64" ;; \
*) echo "unsupported arch: $ARCH_RAW" >&2; exit 1 ;; \
esac \
&& if [ "$GITLEAKS_VERSION" = "latest" ]; then \
GL_TAG="$(curl -fsSI -o /dev/null -w '%{redirect_url}' https://github.com/gitleaks/gitleaks/releases/latest | sed 's|.*/||')"; \
else \
GL_TAG="$GITLEAKS_VERSION"; \
fi \
&& GL_VER="${GL_TAG#v}" \
&& curl -fsSL "https://github.com/gitleaks/gitleaks/releases/download/${GL_TAG}/gitleaks_${GL_VER}_linux_${GL_ARCH}.tar.gz" -o /tmp/gitleaks.tar.gz \
&& tar -xzf /tmp/gitleaks.tar.gz -C /usr/local/bin gitleaks \
&& chmod 0755 /usr/local/bin/gitleaks \
&& rm /tmp/gitleaks.tar.gz

# Rust Token Killer: CLI proxy that cuts token usage on dev operations.
# Env var goes on the sh side of the pipe so the installer (not curl) sees it.
RUN curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/master/install.sh \
| RTK_INSTALL_DIR=/usr/local/bin sh

RUN mkdir -p /commandhistory \
&& touch /commandhistory/.bash_history /commandhistory/.zsh_history \
&& chown -R vscode:vscode /commandhistory \
&& echo 'export HISTFILE=/commandhistory/.bash_history' >> /etc/bash.bashrc

# Clipboard bridge: pbpaste/xclip/wl-paste all proxy to a host Unix socket
# bind-mounted at /host-clipboard/clipboard.sock. If the host clipboard
# server is not running, the shim fails benignly with "connection refused".
RUN printf '#!/usr/bin/env bash\nexec socat - UNIX-CONNECT:/host-clipboard/clipboard.sock\n' \
> /usr/local/bin/aidc-clipboard-recv \
&& chmod 0755 /usr/local/bin/aidc-clipboard-recv \
&& ln -sf /usr/local/bin/aidc-clipboard-recv /usr/local/bin/pbpaste \
&& ln -sf /usr/local/bin/aidc-clipboard-recv /usr/local/bin/xclip \
&& ln -sf /usr/local/bin/aidc-clipboard-recv /usr/local/bin/wl-paste

# Bootstrap Claude OAuth + skip the first-run welcome screen when a token
# is present. Idempotent: returns immediately if ~/.claude.json already has
# hasCompletedOnboarding=true. Invoked by aidc::run_tool before 'aidc claude'.
COPY --chown=root:root <<'EOF' /usr/local/bin/aidc-bootstrap-claude
#!/usr/bin/env bash
set -euo pipefail
[[ -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]] || exit 0

config="$HOME/.claude.json"
if [[ -f "$config" ]] && python3 - "$config" <<'PY' 2>/dev/null
import json, sys
try:
sys.exit(0 if json.load(open(sys.argv[1])).get("hasCompletedOnboarding") else 1)
except Exception:
sys.exit(1)
PY
then
exit 0
fi

claude -p ok >/dev/null 2>&1 || true

python3 - "$config" <<'PY'
import json, os, sys
p = sys.argv[1]
try:
cfg = json.load(open(p))
except (FileNotFoundError, ValueError):
cfg = {}
cfg["hasCompletedOnboarding"] = True
with open(p, "w") as f:
json.dump(cfg, f, indent=2)
f.write("\n")
PY
EOF
RUN chmod 0755 /usr/local/bin/aidc-bootstrap-claude

USER vscode

ENV HOME=/home/vscode
# ~/.pmg/bin is FIRST on PATH on purpose. pmg interception rides on these PATH
# shims, NOT on the shell aliases pmg also writes to ~/.zshrc/~/.bashrc: build
# RUN steps and exec'd agent subprocesses never source rc files, so only the
# shims (first on PATH at build and runtime) reliably gate npm/pip/uv/etc.
ENV PATH=/home/vscode/.pmg/bin:/home/vscode/.local/bin:/usr/local/bin:/usr/bin:/bin

# Wire pmg in before ANY user-level package install so nothing slips past it.
RUN pmg setup install \
&& pmg setup doctor || true

# Coding agents as native prebuilt binaries. Each installer drops a binary into
# ~/.local/bin (already on PATH) via plain curl — no npm dependency resolution,
# so pmg has nothing to vet here; the "pmg first" invariant above still holds.
# Grok's binary is forced into ~/.local/bin via GROK_BIN_DIR so the grok_home
# volume mounted at ~/.grok at runtime cannot shadow it.
RUN mkdir -p \
/home/vscode/.local/bin \
/home/vscode/.claude \
/home/vscode/.codex \
/home/vscode/.config/opencode \
/home/vscode/.cursor-agent \
/home/vscode/.grok \
&& curl -fsSL https://claude.ai/install.sh | bash \
&& curl -fsSL https://chatgpt.com/codex/install.sh | sh \
&& curl -fsSL https://opencode.ai/install | bash -s -- --no-modify-path \
&& curl -fsS https://cursor.com/install | bash \
&& { cursor-agent --version || true; } \
&& curl -fsSL https://x.ai/cli/install.sh | GROK_BIN_DIR=/home/vscode/.local/bin bash
Loading