From cdeccebaadff640fec15c4dafb5c544547c48a78 Mon Sep 17 00:00:00 2001 From: swibrow <15628653+swibrow@users.noreply.github.com> Date: Sat, 25 Jul 2026 08:20:44 +0200 Subject: [PATCH] feat(dev-desktop): add Arch/Hyprland streaming dev desktop image Arch Linux workstation image that streams a headless Hyprland session over Sunshine to any Moonlight client, with cloud tooling and AI coding agents preinstalled. Notes on the conventions this image bends: - amd64 only. archlinux has no official arm64 image. - Hyprland is PID 1 and starts the session via exec-once, so no supervisor or init framework is introduced. - pacman packages float because Arch is rolling. The base digest, Sunshine and the four npm agents are pinned and annotated for Renovate. Sunshine is installed from the upstream Arch package published as a release asset, which avoids pulling in AUR and makepkg. The image requires a DRM render node even though it runs headless: aquamarine only builds a GBM allocator from a backend reporting drmFD() >= 0 and the headless backend returns -1. start-desktop checks for /dev/dri/renderD128 and fails with an actionable message instead of the "no allocator available" error. Signed-off-by: swibrow <15628653+swibrow@users.noreply.github.com> --- README.md | 1 + images/dev-desktop/Dockerfile | 178 +++++++++++++++++++++++++++++++ images/dev-desktop/metadata.yaml | 8 ++ 3 files changed, 187 insertions(+) create mode 100644 images/dev-desktop/Dockerfile create mode 100644 images/dev-desktop/metadata.yaml diff --git a/README.md b/README.md index fe2e8ae..15efafd 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Public container images for cloud work, built multi-arch and rootless with secur |---|---| | `ghcr.io/cloudsnacks/actions-runner` | Rootless GitHub Actions runner for [Actions Runner Controller](https://github.com/actions/actions-runner-controller) | | `ghcr.io/cloudsnacks/claude-code` | Headless Claude Code agent with git-repo and agent-profile bootstrap | +| `ghcr.io/cloudsnacks/dev-desktop` | Arch Linux dev workstation with Hyprland streamed over [Sunshine](https://github.com/LizardByte/Sunshine)/Moonlight, cloud tooling, and AI coding agents (`linux/amd64` only, needs `/dev/dri`) | | `ghcr.io/cloudsnacks/infisical-mcp` | Infisical MCP server (secrets management over MCP) | | `ghcr.io/cloudsnacks/kubectl` | Rootless kubectl CLI | | `ghcr.io/cloudsnacks/sandbox-agent` | Rootless base image for sandboxed coding agents (Node, Python, uv, git, gh, ripgrep) | diff --git a/images/dev-desktop/Dockerfile b/images/dev-desktop/Dockerfile new file mode 100644 index 0000000..546472a --- /dev/null +++ b/images/dev-desktop/Dockerfile @@ -0,0 +1,178 @@ +FROM archlinux:base@sha256:592e11bd99ab579f933a0cb77a8f66e2f3ae57f5eafacf13aea44a6e98ef21ae + +# Sunshine ships an official Arch package as a release asset, so no AUR/makepkg is needed. +# renovate: datasource=github-releases depName=LizardByte/Sunshine extractVersion=^v(?.+)$ +ARG SUNSHINE_VERSION=2026.516.143833 +# renovate: datasource=npm depName=@anthropic-ai/claude-code +ARG CLAUDE_CODE_VERSION=2.1.220 +# renovate: datasource=npm depName=@openai/codex +ARG CODEX_VERSION=0.145.0 +# renovate: datasource=npm depName=@google/gemini-cli +ARG GEMINI_CLI_VERSION=0.52.0 +# renovate: datasource=npm depName=opencode-ai +ARG OPENCODE_VERSION=1.18.5 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +ENV LANG=C.UTF-8 + +# Trim docs/locales before any package lands so every later install stays lean. +# These are [options] directives, so they are inserted into that section rather +# than appended (appending would land them in the last repo section). +# DisableSandbox: pacman 7 drops to the 'alpm' user behind seccomp/Landlock to +# download, which fails under emulated and nested builds. Build-time only. +RUN printf '%s\n' \ + 'NoExtract = usr/share/doc/*' \ + 'NoExtract = usr/share/man/* !usr/share/man/man1/*' \ + 'NoExtract = usr/share/locale/* !usr/share/locale/en*/*' \ + 'NoExtract = usr/share/i18n/*' \ + 'DisableSandbox' \ + > /tmp/pacman-opts \ + && sed -i '/^\[options\]/r /tmp/pacman-opts' /etc/pacman.conf \ + && rm -f /tmp/pacman-opts + +# archlinux:base is rebuilt weekly; refresh the keyring first or signatures fail +# on any package newer than the snapshot. +RUN pacman -Sy --noconfirm archlinux-keyring \ + && pacman -Su --noconfirm \ + && pacman -S --noconfirm --needed \ + gcc make pkgconf \ + git git-lfs github-cli openssh \ + curl wget jq less tree rsync unzip zip which man-db \ + ripgrep fd eza bat fzf zoxide starship tmux zsh neovim htop btop \ + python python-pip uv nodejs npm go rust \ + kubectl helm k9s kubectx stern opentofu terraform aws-cli-v2 azure-cli \ + chezmoi \ + hyprland xdg-desktop-portal xdg-desktop-portal-hyprland xorg-xwayland \ + foot waybar wofi mako \ + pipewire pipewire-pulse wireplumber \ + mesa vulkan-swrast intel-media-driver libva-utils \ + dbus polkit seatd avahi \ + ttf-jetbrains-mono-nerd noto-fonts \ + && pacman -Scc --noconfirm \ + && rm -rf /var/cache/pacman/pkg/* /var/lib/pacman/sync/* \ + && XDG_RUNTIME_DIR=/tmp Hyprland --version \ + && kubectl version --client \ + && terraform version \ + && node --version + +# pacman -U pulls the package's dependencies from the repos, so the sync DB that +# the previous layer removed has to be refreshed first. +RUN curl -fsSL -o /tmp/sunshine.pkg.tar.zst \ + "https://github.com/LizardByte/Sunshine/releases/download/v${SUNSHINE_VERSION}/sunshine-${SUNSHINE_VERSION}-1-x86_64.pkg.tar.zst" \ + && pacman -Sy --noconfirm \ + && pacman -U --noconfirm /tmp/sunshine.pkg.tar.zst \ + && rm -f /tmp/sunshine.pkg.tar.zst \ + && pacman -Scc --noconfirm \ + && rm -rf /var/cache/pacman/pkg/* /var/lib/pacman/sync/* \ + && sunshine --version + +# claude-code and opencode fetch their platform-native binary in postinstall, which +# recent npm blocks by default; they are opted in explicitly rather than globally. +RUN npm install -g --no-fund --no-audit \ + --allow-scripts=@anthropic-ai/claude-code,opencode-ai \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + "@openai/codex@${CODEX_VERSION}" \ + "@google/gemini-cli@${GEMINI_CLI_VERSION}" \ + "opencode-ai@${OPENCODE_VERSION}" \ + && npm cache clean --force \ + && claude --version \ + && codex --version \ + && gemini --version \ + && opencode --version + +# video/render gate /dev/dri, input gates /dev/uinput for Moonlight input injection. +RUN groupadd --gid 1001 dev \ + && useradd --uid 1001 --gid dev --create-home --shell /usr/bin/zsh dev \ + && for g in video render input audio seat; do \ + getent group "${g}" > /dev/null || groupadd --system "${g}"; \ + done \ + && usermod -aG video,render,input,audio,seat dev \ + && mkdir -p /workspace /run/user/1001 \ + && chown dev:dev /workspace /run/user/1001 \ + && chmod 700 /run/user/1001 + +# Hyprland is PID 1 and starts the session via exec-once; no supervisor is used. +# Written to the user's primary config path rather than relying on an /etc/xdg fallback. +# The directory is dev-owned so Hyprland can write state; the config itself only +# needs to be readable. +RUN install -d -o dev -g dev /home/dev/.config /home/dev/.config/hypr \ + && cat > /home/dev/.config/hypr/hyprland.conf <<'EOF' +# Headless session for remote streaming; the output is created at runtime +# because the headless backend starts with zero outputs. +monitor = HEADLESS-1, 1920x1080@60, 0x0, 1 + +exec-once = hyprctl output create headless HEADLESS-1 +exec-once = /usr/bin/wireplumber +exec-once = /usr/bin/sunshine +exec-once = /usr/bin/waybar +exec-once = /usr/bin/mako + +input { + kb_layout = us + follow_mouse = 1 +} + +general { + gaps_in = 4 + gaps_out = 8 + border_size = 2 +} + +decoration { + rounding = 6 +} + +animations { + enabled = false +} + +misc { + disable_hyprland_logo = true + disable_splash_rendering = true +} + +$mod = SUPER +bind = $mod, Return, exec, /usr/bin/foot +bind = $mod, Q, killactive, +bind = $mod, E, exec, /usr/bin/wofi --show drun +bind = $mod, F, fullscreen, +EOF + +# Fail with a readable message instead of aquamarine's "no allocator available": +# Hyprland's headless backend reports drmFD() == -1, so a real render node must +# be present for a GBM allocator to exist. +RUN cat > /usr/local/bin/start-desktop <<'START' && chmod 0755 /usr/local/bin/start-desktop +#!/usr/bin/env bash +set -euo pipefail + +export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" +mkdir -p "${XDG_RUNTIME_DIR}" +chmod 700 "${XDG_RUNTIME_DIR}" + +if [[ ! -e /dev/dri/renderD128 ]]; then + echo "FATAL: /dev/dri/renderD128 is missing." >&2 + echo "Hyprland cannot start without a DRM render node, even headless." >&2 + echo "Mount /dev/dri into the pod and set the matching supplementalGroups." >&2 + exit 1 +fi + +exec Hyprland "$@" +START + +ENV DOTFILES_REPO=https://github.com/swibrow/dotfiles.git \ + XDG_RUNTIME_DIR=/run/user/1001 \ + XDG_SESSION_TYPE=wayland \ + XDG_CURRENT_DESKTOP=Hyprland \ + NPM_CONFIG_PREFIX=/home/dev/.npm \ + PATH=/home/dev/.npm/bin:/home/dev/.local/bin:/home/dev/go/bin:/home/dev/.cargo/bin:/usr/local/bin:/usr/bin:/bin + +USER dev +WORKDIR /workspace + +EXPOSE 47984/tcp 47989/tcp 47990/tcp 48010/tcp +EXPOSE 47998/udp 47999/udp 48000/udp 48002/udp + +LABEL org.opencontainers.image.description="Arch Linux dev workstation with Hyprland streamed over Sunshine/Moonlight, cloud tooling, and AI coding agents" + +CMD ["/usr/local/bin/start-desktop"] diff --git a/images/dev-desktop/metadata.yaml b/images/dev-desktop/metadata.yaml new file mode 100644 index 0000000..871b928 --- /dev/null +++ b/images/dev-desktop/metadata.yaml @@ -0,0 +1,8 @@ +--- +name: dev-desktop +description: Arch Linux dev workstation with Hyprland streamed over Sunshine/Moonlight +version: 1.0.0 +platforms: + # archlinux has no official arm64 image, so this image is amd64-only. + - linux/amd64 +test: Hyprland --version && sunshine --version && claude --version && codex --version && gemini --version && opencode --version && kubectl version --client && helm version --short && terraform version && tofu version && node --version && python3 --version && go version && uv --version && rg --version && nvim --version && chezmoi --version && test "$(whoami)" = dev