diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3ba75ce..cc89624 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -34,8 +34,8 @@ permissions: contents: read jobs: - build: - name: Build and smoke test + validate: + name: Validate source and release metadata runs-on: ubuntu-latest steps: @@ -64,6 +64,25 @@ jobs: - name: Validate scripts and pinned metadata run: make check + build: + name: Build and smoke test (${{ matrix.arch }}) + needs: validate + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + + steps: + - name: Checkout + uses: actions/checkout@v5 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 @@ -72,18 +91,22 @@ jobs: with: context: . load: true - platforms: linux/amd64 + platforms: ${{ matrix.platform }} push: false - tags: pdparchitect/buzznode:ci - cache-from: type=gha - cache-to: type=gha,mode=max + tags: pdparchitect/buzznode:ci-${{ matrix.arch }} + cache-from: type=gha,scope=buzznode-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=buzznode-${{ matrix.arch }} - name: Smoke test shell: bash + env: + ARCH: ${{ matrix.arch }} + IMAGE: pdparchitect/buzznode:ci-${{ matrix.arch }} + PLATFORM: ${{ matrix.platform }} run: | set -euo pipefail - container="buzznode-ci" + container="buzznode-ci-${ARCH}" cleanup() { docker rm --force "$container" >/dev/null 2>&1 || true } @@ -91,10 +114,10 @@ jobs: docker run --detach \ --name "$container" \ - --platform linux/amd64 \ + --platform "$PLATFORM" \ --shm-size 1g \ --publish 127.0.0.1:16903:6901 \ - pdparchitect/buzznode:ci + "$IMAGE" ready=false for attempt in $(seq 1 60); do @@ -112,16 +135,6 @@ jobs: exit 1 fi - docker exec "$container" bash -ec ' - for command in agent-runtime-login buzznode buzz buzz-acp \ - buzz-agent buzz-dev-mcp codex codex-acp claude \ - claude-agent-acp goose; do - command -v "$command" >/dev/null - done - ! command -v buzz-desktop >/dev/null - ! command -v buzz-relay >/dev/null - ! command -v postgres >/dev/null - curl -fsS http://127.0.0.1:6901/ >/dev/null - ' - - echo "Buzznode is ready with its desktop and agent runtimes" + bash tests/smoke-container.sh "$container" "$ARCH" + + echo "Buzznode is ready with its desktop and agent runtimes on $PLATFORM" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index fe85872..4f35374 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -40,9 +40,23 @@ jobs: exit 1 fi - publish: + build: + name: Build release image (${{ matrix.arch }}) needs: validate - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-24.04 + - arch: arm64 + platform: linux/arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + permissions: + contents: read + packages: write steps: - name: Checkout @@ -58,6 +72,69 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push image by digest + id: build + uses: docker/build-push-action@v7 + with: + context: . + platforms: ${{ matrix.platform }} + push: true + outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + labels: | + org.opencontainers.image.title=Buzznode + org.opencontainers.image.description=One persistent browser-accessible computer for one Buzz agent + cache-from: type=gha,scope=buzznode-${{ matrix.arch }} + cache-to: type=gha,mode=max,scope=buzznode-${{ matrix.arch }} + provenance: mode=max + sbom: true + + - name: Export image digest + shell: bash + env: + DIGEST: ${{ steps.build.outputs.digest }} + run: | + mkdir -p /tmp/digests + touch "/tmp/digests/${DIGEST#sha256:}" + + - name: Upload image digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + + publish: + name: Publish multi-architecture release + needs: + - validate + - build + runs-on: ubuntu-24.04 + permissions: + contents: write + packages: write + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Download image digests + uses: actions/download-artifact@v5 + with: + path: /tmp/digests + pattern: digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Extract image metadata id: meta uses: docker/metadata-action@v6 @@ -70,30 +147,36 @@ jobs: type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=raw,value=latest,enable=${{ !contains(github.ref_name, '-') }} - labels: | - org.opencontainers.image.title=Buzznode - org.opencontainers.image.description=One persistent browser-accessible computer for one Buzz agent - - name: Build and publish image - id: build - uses: docker/build-push-action@v7 - with: - context: . - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - provenance: mode=max - sbom: true + - name: Create multi-architecture image + id: manifest + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + run: | + set -euo pipefail + + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' \ + <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf "${IMAGE}@sha256:%s " /tmp/digests/* | \ + sed 's|/tmp/digests/||g') + + digest="$( + docker buildx imagetools inspect \ + "${IMAGE}:${GITHUB_REF_NAME}" | + sed -n 's/^Digest:[[:space:]]*//p' | + head -1 + )" + test -n "$digest" + echo "digest=$digest" >> "$GITHUB_OUTPUT" - name: Generate release notes id: notes shell: bash env: IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - DIGEST: ${{ steps.build.outputs.digest }} + DIGEST: ${{ steps.manifest.outputs.digest }} run: | set -euo pipefail diff --git a/CHANGELOG.md b/CHANGELOG.md index b369395..1638a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,61 @@ All notable changes to Buzznode are documented here, following ## [Unreleased] +## [0.2.0] - 2026-07-27 + +### Added + +- Publish one multi-architecture Buzznode image for `linux/amd64` and + `linux/arm64`, with native builds and headed-browser smoke tests for both + architectures before their digests are combined into a release manifest. +- Build the pinned headless Buzz tools from their exact upstream source commit + on ARM64, where upstream does not publish a Linux package. + +### Changed + +- Keep Google Chrome on AMD64 and use signed Debian Chromium on ARM64 behind + the same launcher, Buzz-branded GTK theme, managed policy, and desktop + integration. +- Select native Buzz, Goose, yq, Cortile, and KasmVNC artifacts for the target + architecture, and let local builds select the host architecture by default. +- Pin GTK and Chrome's Linux UI typography to Noto Sans 9, matching every + Openbox title, menu, and on-screen-display font declaration instead of + inheriting GTK's larger Sans 10 default. +- Give Chrome a self-contained, Buzz-branded near-black GTK system theme that + darkens native menus and popups as well as the tab strip, active tab, + toolbar, controls, and address field; render Chrome's window controls from + the same XBM masks and state colors as Openbox, square the GTK-controlled + outer frame corners, and replace the bundled welcome card with the + terminal's ASCII banner on pure black. +- Remove the window handle, which drew a second line under the client area + with a resize grip boxed off at each end. Resizing stays available through + the window edges and corners and through Alt+right-drag anywhere on the + frame. +- Declare Codex's sandbox mode as `danger-full-access` at boot. Codex sandboxes + commands with bubblewrap, which cannot create a user namespace inside the + container, so no sandbox mode is enforceable and Codex warned on every start + about falling back to its bundled copy. Override with `BUZZNODE_CODEX_SANDBOX_MODE`. +- Start terminals in `/workspace` instead of the home directory, so the desktop + and the agent harness work in the same tree. Openbox chdirs to `$HOME` at + startup whatever directory it was started from and hands that to everything + it launches, so this is set in the shell - the one place every terminal + passes through - and only when the shell landed in `$HOME`, which leaves + non-interactive shells and deliberate directories alone. +- Widen the window grab margin with client padding. With the handle gone the + frame offered 1px to grab at the bottom against a 28px titlebar, so the + bottom corners were nearly unhittable. Client padding adds frame around the + client and paints it in the frame background, taking the grabbable ring from + 1px to 7px without drawing anything new. + +### Fixed + +- Record the workspace as trusted for Codex and Claude Code at boot. Both + prompt once per directory before working in it, and `codex-acp` consults the + same `trust_level`, so the harness `buzznode launch` starts unattended in + `/workspace` would stop on a prompt nobody is present to answer, with the + reason buried in its log. Set `BUZZNODE_TRUST_WORKSPACE=false` to keep the + prompts. + ## [0.1.0] - 2026-07-26 ### Added diff --git a/Dockerfile b/Dockerfile index fd63503..41fe2e5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,56 @@ # Buzznode - a browser-accessible computer for one Buzz agent. # # Buzznode connects to an existing relay and deliberately contains neither the -# Buzz Desktop client nor local relay/backing services. Buzz binaries are amd64. +# Buzz Desktop client nor local relay/backing services. + +# Upstream publishes a Linux package only for AMD64. Extract its headless tools +# there; on ARM64, build the same immutable tag and exact commit from source. +FROM rust:1.95-bookworm AS buzz-tools + +ARG TARGETARCH +ARG BUZZ_VERSION=0.4.26 +ARG BUZZ_DEB_SHA256=1b520756ecfc28ad81981a2cd5cc6688f785f447b3f5d8d553544906f59bf521 +ARG BUZZ_SOURCE_SHA=0096d710ed2e6abab19aaf7cdc14e3ee603d7ec8 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl git pkg-config && \ + rm -rf /var/lib/apt/lists/* + +RUN set -eux; \ + arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + mkdir -p /out; \ + if [ "$arch" = "amd64" ]; then \ + buzz_deb="/tmp/Buzz_${BUZZ_VERSION}_amd64.deb"; \ + extract_dir="$(mktemp -d)"; \ + curl -fsSL \ + "https://github.com/block/buzz/releases/download/v${BUZZ_VERSION}/Buzz_${BUZZ_VERSION}_amd64.deb" \ + -o "$buzz_deb"; \ + echo "${BUZZ_DEB_SHA256} ${buzz_deb}" | sha256sum -c -; \ + dpkg-deb --extract "$buzz_deb" "$extract_dir"; \ + for binary in buzz buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr; do \ + install -m 0755 "$extract_dir/usr/bin/$binary" "/out/$binary"; \ + done; \ + rm -rf "$extract_dir" "$buzz_deb"; \ + elif [ "$arch" = "arm64" ]; then \ + git clone --branch "v${BUZZ_VERSION}" --depth 1 \ + https://github.com/block/buzz.git /tmp/buzz; \ + cd /tmp/buzz; \ + test "$(git rev-parse HEAD)" = "$BUZZ_SOURCE_SHA"; \ + cargo build --locked --release \ + -p buzz-cli \ + -p buzz-acp \ + -p buzz-agent \ + -p buzz-dev-mcp \ + -p git-credential-nostr; \ + for binary in buzz buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr; do \ + install -m 0755 "target/release/$binary" "/out/$binary"; \ + done; \ + rm -rf /tmp/buzz; \ + else \ + echo "Buzznode does not support linux/$arch" >&2; \ + exit 1; \ + fi; \ + strip /out/* # ═══════════════════════════════════════════════════════════════════ # Stage: core - shared runtime/tooling baseline for agent workloads. @@ -15,8 +64,11 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"] ARG TARGETARCH ENV DEBIAN_FRONTEND=noninteractive -RUN test "${TARGETARCH:-amd64}" = "amd64" || \ - { echo "Buzznode currently supports linux/amd64 only" >&2; exit 1; } +RUN arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + case "$arch" in \ + amd64|arm64) ;; \ + *) echo "Buzznode does not support linux/$arch" >&2; exit 1 ;; \ + esac # Core tools for the node and its coding-agent runtimes. RUN apt-get update && apt-get install -y --no-install-recommends \ @@ -56,12 +108,19 @@ RUN npm install -g \ # Goose exposes ACP natively, so it does not need a separate adapter. ARG GOOSE_VERSION=1.44.0 -ARG GOOSE_ARCHIVE_SHA256=07febc8b4f73bdfdc3ece3d34d0e21b005f3a4f43008f95b85d6538da8f6bac1 -RUN goose_archive="/tmp/goose-${GOOSE_VERSION}.tar.gz" && \ +ARG GOOSE_AMD64_SHA256=07febc8b4f73bdfdc3ece3d34d0e21b005f3a4f43008f95b85d6538da8f6bac1 +ARG GOOSE_ARM64_SHA256=da6cb005d421b0bdcb83fe8386ba5ae8060ef17adf64641a684d4fc4b9e1c15f +RUN arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + case "$arch" in \ + amd64) goose_arch=x86_64; goose_sha="$GOOSE_AMD64_SHA256" ;; \ + arm64) goose_arch=aarch64; goose_sha="$GOOSE_ARM64_SHA256" ;; \ + *) echo "Unsupported Goose architecture: $arch" >&2; exit 1 ;; \ + esac; \ + goose_archive="/tmp/goose-${GOOSE_VERSION}.tar.gz" && \ curl -fsSL --retry 5 --retry-all-errors --connect-timeout 20 \ - "https://github.com/aaif-goose/goose/releases/download/v${GOOSE_VERSION}/goose-x86_64-unknown-linux-gnu.tar.gz" \ + "https://github.com/aaif-goose/goose/releases/download/v${GOOSE_VERSION}/goose-${goose_arch}-unknown-linux-gnu.tar.gz" \ -o "$goose_archive" && \ - echo "${GOOSE_ARCHIVE_SHA256} ${goose_archive}" | sha256sum -c - && \ + echo "${goose_sha} ${goose_archive}" | sha256sum -c - && \ goose_dir="$(mktemp -d)" && \ tar -xzf "$goose_archive" -C "$goose_dir" && \ install -m 0755 "$goose_dir/goose" /usr/local/bin/goose && \ @@ -71,26 +130,21 @@ RUN goose_archive="/tmp/goose-${GOOSE_VERSION}.tar.gz" && \ # Mike Farah yq. ARG YQ_VERSION=4.44.6 -RUN curl -fsSL "https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_amd64" \ +RUN arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + curl -fsSL "https://github.com/mikefarah/yq/releases/download/v${YQ_VERSION}/yq_linux_${arch}" \ -o /usr/local/bin/yq && \ chmod +x /usr/local/bin/yq && \ yq --version -# Extract only the headless Buzz tools from the release package. Installing the -# package itself would also install buzz-desktop, which does not belong here. +# Copy only the headless Buzz tools. The builder extracts the verified upstream +# package on AMD64 and builds the same pinned source tag on ARM64. ARG BUZZ_VERSION=0.4.26 ARG BUZZ_DEB_SHA256=1b520756ecfc28ad81981a2cd5cc6688f785f447b3f5d8d553544906f59bf521 -RUN buzz_deb="/tmp/Buzz_${BUZZ_VERSION}_amd64.deb"; \ - extract_dir="$(mktemp -d)"; \ - curl -fsSL \ - "https://github.com/block/buzz/releases/download/v${BUZZ_VERSION}/Buzz_${BUZZ_VERSION}_amd64.deb" \ - -o "$buzz_deb"; \ - echo "${BUZZ_DEB_SHA256} ${buzz_deb}" | sha256sum -c -; \ - dpkg-deb --extract "$buzz_deb" "$extract_dir"; \ - for binary in buzz buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr; do \ - install -m 0755 "$extract_dir/usr/bin/$binary" "/usr/local/bin/$binary"; \ +ARG BUZZ_SOURCE_SHA=0096d710ed2e6abab19aaf7cdc14e3ee603d7ec8 +COPY --from=buzz-tools /out/ /usr/local/bin/ +RUN for binary in buzz buzz-acp buzz-agent buzz-dev-mcp git-credential-nostr; do \ + test -x "/usr/local/bin/$binary"; \ done; \ - rm -rf "$extract_dir" "$buzz_deb"; \ command -v buzz; \ command -v buzz-acp; \ command -v buzz-agent; \ @@ -133,17 +187,25 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ # Cortile provides optional dynamic tiling on top of Openbox. ARG CORTILE_VERSION=2.5.2 -RUN tmp_dir="$(mktemp -d)"; \ +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) cortile_arch=amd64 ;; \ + arm64) cortile_arch=arm64 ;; \ + *) echo "Unsupported Cortile architecture: $arch" >&2; exit 1 ;; \ + esac; \ + tmp_dir="$(mktemp -d)"; \ curl -fsSL \ - "https://github.com/leukipp/cortile/releases/download/v${CORTILE_VERSION}/cortile_${CORTILE_VERSION}_linux_amd64.tar.gz" \ + "https://github.com/leukipp/cortile/releases/download/v${CORTILE_VERSION}/cortile_${CORTILE_VERSION}_linux_${cortile_arch}.tar.gz" \ | tar -xz -C "$tmp_dir"; \ install -m 0755 "$tmp_dir/cortile" /usr/local/bin/cortile; \ rm -rf "$tmp_dir" # KasmVNC exposes the desktop in a browser. ARG KASMVNC_VERSION=1.4.0 -RUN curl -fsSL \ - "https://github.com/kasmtech/KasmVNC/releases/download/v${KASMVNC_VERSION}/kasmvncserver_noble_${KASMVNC_VERSION}_amd64.deb" \ +RUN arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + curl -fsSL \ + "https://github.com/kasmtech/KasmVNC/releases/download/v${KASMVNC_VERSION}/kasmvncserver_noble_${KASMVNC_VERSION}_${arch}.deb" \ -o /tmp/kasmvnc.deb && \ apt-get update && \ apt-get install -y --no-install-recommends /tmp/kasmvnc.deb && \ @@ -164,14 +226,36 @@ RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor \ apt-get install -y --no-install-recommends docker-ce-cli gh && \ rm -rf /var/lib/apt/lists/* -# Chrome is a secondary browser for documentation and login flows. -RUN curl -fsSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \ - -o /tmp/chrome.deb && \ - apt-get update && \ - apt-get install -y --no-install-recommends /tmp/chrome.deb && \ - rm -f /tmp/chrome.deb && \ - rm -rf /var/lib/apt/lists/* && \ - rm -f /usr/local/bin/chromium +# Google does not publish Chrome for Linux ARM64. Keep Chrome on AMD64 and use +# Debian's signed Chromium package on ARM64 behind the same Buzznode launcher. +RUN set -eux; \ + arch="${TARGETARCH:-$(dpkg --print-architecture)}"; \ + if [ "$arch" = "amd64" ]; then \ + curl -fsSL https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb \ + -o /tmp/browser.deb; \ + apt-get update; \ + apt-get install -y --no-install-recommends /tmp/browser.deb; \ + rm -f /tmp/browser.deb; \ + else \ + mkdir -p /etc/apt/keyrings; \ + curl -fsSL https://ftp-master.debian.org/keys/archive-key-12.asc \ + -o /etc/apt/keyrings/debian-archive-key-12.asc; \ + printf '%s\n' \ + 'deb [arch=arm64 signed-by=/etc/apt/keyrings/debian-archive-key-12.asc] https://deb.debian.org/debian bookworm main' \ + > /etc/apt/sources.list.d/debian-bookworm.list; \ + printf '%s\n' \ + 'Package: *' \ + 'Pin: release n=bookworm' \ + 'Pin-Priority: 100' \ + > /etc/apt/preferences.d/debian-bookworm; \ + apt-get update; \ + apt-get install -y --no-install-recommends chromium; \ + rm -f \ + /etc/apt/keyrings/debian-archive-key-12.asc \ + /etc/apt/preferences.d/debian-bookworm \ + /etc/apt/sources.list.d/debian-bookworm.list; \ + fi; \ + rm -rf /var/lib/apt/lists/* # ═══════════════════════════════════════════════════════════════════ # Stage: buzznode - one persistent desktop connected to an existing Buzz relay. @@ -203,6 +287,22 @@ RUN if id -u agent >/dev/null 2>&1; then \ ENV HOME=/home/buzznode \ BROWSER=chromium +ENV GTK_THEME=Buzznode +# Chrome and Chromium ask GTK for embedded symbolic window-control resources. +# Overlay only those four resources so their custom frames use the exact +# Openbox glyph masks. +ENV G_RESOURCE_OVERLAYS=/org/gtk/libgtk=/usr/share/buzznode/gtk-overlay + +# Browser popup menus come from Linux's native color pipeline rather than +# extension-theme colors. A GTK system theme therefore styles the menus, +# dialogs, toolbar, tabs, and omnibox as one coherent near-black surface. +COPY gtk/Buzznode /usr/share/themes/Buzznode +COPY gtk/generate-resource-overlay.py /tmp/generate-gtk-resource-overlay.py +COPY openbox/theme /tmp/openbox-theme +# Generate real symbolic PNGs directly from the Openbox XBM source assets. +RUN python3 /tmp/generate-gtk-resource-overlay.py \ + /tmp/openbox-theme /usr/share/buzznode/gtk-overlay && \ + rm -rf /tmp/generate-gtk-resource-overlay.py /tmp/openbox-theme RUN echo "agent ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/agent && \ chmod 0440 /etc/sudoers.d/agent && \ @@ -220,9 +320,27 @@ RUN chmod +x /tmp/kasm-patch.sh && /tmp/kasm-patch.sh && rm /tmp/kasm-patch.sh # Match the Buzz website's chartreuse background and subtle dot grid. COPY wallpaper/buzz-grid.svg /usr/share/backgrounds/buzz-grid.svg -RUN mkdir -p /etc/opt/chrome/policies/managed && \ +# Browser preferences. `system_theme: 1` selects GTK on Linux; unlike an +# extension theme, it also reaches native menus and other popup surfaces. +RUN for config_dir in google-chrome chromium; do \ + mkdir -p "/home/buzznode/.config/$config_dir/Default"; \ + printf '{\n "browser": {\n "has_seen_welcome_page": true,\n "check_default_browser": false\n },\n "bookmark_bar": { "show_on_all_tabs": false },\n "distribution": {\n "skip_first_run_ui": true,\n "show_welcome_page": false,\n "import_bookmarks": false,\n "make_chrome_default_for_user": false,\n "suppress_first_run_default_browser_prompt": true\n },\n "extensions": {\n "theme": {\n "system_theme": 1\n }\n }\n}' \ + > "/home/buzznode/.config/$config_dir/Default/Preferences"; \ + touch "/home/buzznode/.config/$config_dir/First Run"; \ + done && \ + chown -R agent:agent \ + /home/buzznode/.config/google-chrome \ + /home/buzznode/.config/chromium + +# Suppress the default-browser prompt via managed policy. Do not set +# BrowserThemeColor here: that policy overrides the GTK system theme. +RUN mkdir -p \ + /etc/opt/chrome/policies/managed \ + /etc/chromium/policies/managed && \ printf '{\n "DefaultBrowserSettingEnabled": false,\n "BrowserSignin": 0,\n "HomepageLocation": "file:///opt/browser/index.html",\n "HomepageIsNewTabPage": false,\n "ShowHomeButton": true\n}\n' \ - > /etc/opt/chrome/policies/managed/chrome-policy.json + > /etc/opt/chrome/policies/managed/buzznode-policy.json && \ + cp /etc/opt/chrome/policies/managed/buzznode-policy.json \ + /etc/chromium/policies/managed/buzznode-policy.json COPY openbox/rc.xml /etc/xdg/openbox/rc.xml COPY openbox/menu.xml /etc/xdg/openbox/menu.xml diff --git a/IMAGE-SIZE.md b/IMAGE-SIZE.md index b01f022..af021a9 100644 --- a/IMAGE-SIZE.md +++ b/IMAGE-SIZE.md @@ -4,6 +4,9 @@ Buzznode runs its agent through the headless `buzz-acp` harness, yet roughly a quarter of the image is a graphical desktop. This document records what that costs, why it is there, and which parts have been trimmed. +The measurements below are from the AMD64 image, which uses Google Chrome. +ARM64 uses Chromium and may have a different package and layer breakdown. + Regenerate every number here with: ```bash diff --git a/Makefile b/Makefile index 007c785..17e73d5 100644 --- a/Makefile +++ b/Makefile @@ -4,15 +4,21 @@ SHELL := /bin/bash DOCKER ?= docker IMAGE ?= pdparchitect/buzznode:local CONTAINER ?= buzznode -PLATFORM ?= linux/amd64 +NATIVE_ARCH := $(shell uname -m | sed \ + -e 's/^x86_64$$/amd64/' \ + -e 's/^aarch64$$/arm64/') +PLATFORM ?= linux/$(NATIVE_ARCH) +TARGETARCH ?= $(word 2,$(subst /, ,$(PLATFORM))) BUZZ_VERSION ?= 0.4.26 BUZZ_DEB_SHA256 ?= 1b520756ecfc28ad81981a2cd5cc6688f785f447b3f5d8d553544906f59bf521 +BUZZ_SOURCE_SHA ?= 0096d710ed2e6abab19aaf7cdc14e3ee603d7ec8 CODEX_VERSION ?= 0.145.0 CLAUDE_CODE_VERSION ?= 2.1.220 CODEX_ACP_VERSION ?= 1.1.7 CLAUDE_ACP_VERSION ?= 0.62.0 GOOSE_VERSION ?= 1.44.0 -GOOSE_ARCHIVE_SHA256 ?= 07febc8b4f73bdfdc3ece3d34d0e21b005f3a4f43008f95b85d6538da8f6bac1 +GOOSE_AMD64_SHA256 ?= 07febc8b4f73bdfdc3ece3d34d0e21b005f3a4f43008f95b85d6538da8f6bac1 +GOOSE_ARM64_SHA256 ?= da6cb005d421b0bdcb83fe8386ba5ae8060ef17adf64641a684d4fc4b9e1c15f BIND_ADDRESS ?= 127.0.0.1 PORT ?= 6904 RELAY_URL ?= @@ -49,36 +55,51 @@ help: @echo " make size-report Report the graphical stack's share of the image" @echo @echo "Overrides: PORT=8080 RELAY_URL=wss://buzz.example RESOLUTION=1600x900" + @echo " PLATFORM=linux/arm64 (default: $(PLATFORM))" @echo " BUZZ_NETWORK=buzz-local VNC_STATS=true" check: bash -n init.sh openbox/autostart shell/agent-runtime-login shell/buzznode \ shell/buzznode-panel-status shell/chromium shell/welcome \ - tests/test-agent-runtime-login.sh tests/test-buzznode.sh + tests/test-agent-runtime-login.sh tests/test-buzznode.sh \ + tests/test-desktop-theme.sh tests/smoke-container.sh bash tests/test-agent-runtime-login.sh bash tests/test-buzznode.sh + bash tests/test-desktop-theme.sh @grep -q "^ARG BUZZ_VERSION=$(BUZZ_VERSION)$$" Dockerfile @grep -q "^ARG BUZZ_DEB_SHA256=$(BUZZ_DEB_SHA256)$$" Dockerfile + @grep -q "^ARG BUZZ_SOURCE_SHA=$(BUZZ_SOURCE_SHA)$$" Dockerfile @grep -q "^ARG CODEX_VERSION=$(CODEX_VERSION)$$" Dockerfile @grep -q "^ARG CLAUDE_CODE_VERSION=$(CLAUDE_CODE_VERSION)$$" Dockerfile @grep -q "^ARG CODEX_ACP_VERSION=$(CODEX_ACP_VERSION)$$" Dockerfile @grep -q "^ARG CLAUDE_ACP_VERSION=$(CLAUDE_ACP_VERSION)$$" Dockerfile @grep -q "^ARG GOOSE_VERSION=$(GOOSE_VERSION)$$" Dockerfile - @grep -q "^ARG GOOSE_ARCHIVE_SHA256=$(GOOSE_ARCHIVE_SHA256)$$" Dockerfile + @grep -q "^ARG GOOSE_AMD64_SHA256=$(GOOSE_AMD64_SHA256)$$" Dockerfile + @grep -q "^ARG GOOSE_ARM64_SHA256=$(GOOSE_ARM64_SHA256)$$" Dockerfile + @grep -q 'window.handle.width: 0' openbox/theme/themerc + @grep -q 'window.client.padding.width: 6' openbox/theme/themerc + @grep -q 'window.client.padding.height: 6' openbox/theme/themerc + @grep -q 'cd /workspace' shell/bashrc + @grep -q 'BUZZNODE_CODEX_SANDBOX_MODE' init.sh + @grep -q 'BUZZNODE_CODEX_SANDBOX_MODE' README.md + @grep -q '\[ -n "$${PS1:-}" \]' shell/bashrc + @grep -q '' openbox/rc.xml @echo "Buzznode metadata, setup CLI, and shell syntax are valid." build: $(DOCKER) build \ --platform "$(PLATFORM)" \ - --build-arg TARGETARCH=amd64 \ + --build-arg "TARGETARCH=$(TARGETARCH)" \ --build-arg "BUZZ_VERSION=$(BUZZ_VERSION)" \ --build-arg "BUZZ_DEB_SHA256=$(BUZZ_DEB_SHA256)" \ + --build-arg "BUZZ_SOURCE_SHA=$(BUZZ_SOURCE_SHA)" \ --build-arg "CODEX_VERSION=$(CODEX_VERSION)" \ --build-arg "CLAUDE_CODE_VERSION=$(CLAUDE_CODE_VERSION)" \ --build-arg "CODEX_ACP_VERSION=$(CODEX_ACP_VERSION)" \ --build-arg "CLAUDE_ACP_VERSION=$(CLAUDE_ACP_VERSION)" \ --build-arg "GOOSE_VERSION=$(GOOSE_VERSION)" \ - --build-arg "GOOSE_ARCHIVE_SHA256=$(GOOSE_ARCHIVE_SHA256)" \ + --build-arg "GOOSE_AMD64_SHA256=$(GOOSE_AMD64_SHA256)" \ + --build-arg "GOOSE_ARM64_SHA256=$(GOOSE_ARM64_SHA256)" \ --tag "$(IMAGE)" \ . @@ -142,15 +163,8 @@ smoke: $(DOCKER) logs --tail 150 "$(CONTAINER)" || true; \ exit 1; \ fi - @$(DOCKER) exec "$(CONTAINER)" bash -ec '\ - for command in agent-runtime-login buzznode buzz buzz-acp buzz-agent buzz-dev-mcp \ - codex codex-acp claude claude-agent-acp goose; do \ - command -v "$$command" >/dev/null; \ - done; \ - ! command -v buzz-desktop >/dev/null; \ - ! command -v buzz-relay >/dev/null; \ - ! command -v postgres >/dev/null; \ - curl -fsS http://127.0.0.1:6901/ >/dev/null' + @DOCKER="$(DOCKER)" bash tests/smoke-container.sh \ + "$(CONTAINER)" "$(TARGETARCH)" @echo "Buzznode is ready with a desktop and one headless agent harness." connection-test: diff --git a/README.md b/README.md index 4a87086..164f9ab 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,6 @@ the agent's relay URL and private key from any other Buzz client. Then: ```bash docker run --detach \ --name buzznode \ - --platform linux/amd64 \ --shm-size 1g \ --publish 127.0.0.1:6904:6901 \ ghcr.io/pdparchitect/buzznode:latest @@ -42,10 +41,11 @@ press Enter to type the relay URL and private key instead. Pick Codex, Claude Code, or Goose, and the node connects to your relay and starts handling messages for that agent. -Buzznode targets `linux/amd64`. This command is for trying the node out: its -state lives in anonymous volumes, so replacing the container loses the -enrollment and leaves the old volumes behind on disk. For anything you intend to -keep, use the [persistent setup](#persistent-setup) below. +Docker selects the native `linux/amd64` or `linux/arm64` image automatically. +This command is for trying the node out: its state lives in anonymous volumes, +so replacing the container loses the enrollment and leaves the old volumes +behind on disk. For anything you intend to keep, use the +[persistent setup](#persistent-setup) below. ## Setup in detail @@ -105,7 +105,6 @@ its anonymous volumes first with `docker rm --force --volumes buzznode`. ```bash docker run --detach \ --name buzznode \ - --platform linux/amd64 \ --restart unless-stopped \ --shm-size 1g \ --publish 127.0.0.1:6904:6901 \ @@ -230,7 +229,8 @@ prefix for another agent. Keeping nodes isolated gives each agent its own: - Codex with `codex-acp`; - Claude Code with `claude-agent-acp`; - Goose with native ACP support; -- Chrome for login flows and browser-based agent tasks; +- Chrome on AMD64 or Chromium on ARM64 for login flows and browser-based agent + tasks; - a terminal, Git, GitHub CLI, Docker CLI, Python, Node.js, pnpm, and common development tools; and - an Openbox desktop exposed through KasmVNC. @@ -240,8 +240,9 @@ Buzznode contains no `buzz-desktop`, `buzz-relay`, PostgreSQL, Redis, or MinIO. The desktop is about a quarter of the image. It is kept because the node is meant to be inspectable, because runtime authentication needs a real browser, and because it is the substrate for computer use: `scrot`, `xdotool`, `wmctrl`, -and Chrome are already present, so an agent can drive the same display a human -watches through KasmVNC. See [IMAGE-SIZE.md](IMAGE-SIZE.md) for the measured +and a Chromium-family browser are already present, so an agent can drive the +same display a human watches through KasmVNC. See +[IMAGE-SIZE.md](IMAGE-SIZE.md) for the measured breakdown and the reasoning, and run `make size-report` to reproduce it. A purpose-built web application could have taken the desktop's place as the way @@ -250,12 +251,12 @@ would be far smaller than an X session. That was considered and set aside. It would be a second product to design, build, secure, and maintain alongside the node itself, which is not where the early effort belongs. More to the point, it would not actually remove the graphical stack. Runtime authentication needs a -real browser, and computer use needs a real display with real input, so Chrome, -Xvnc, the fonts, and the software GL renderer stay in the image either way — -and those are the bulk of the cost. Openbox, tint2, and the rest of the desktop -shell add roughly 22 MiB on top of components already being paid for. Given -that, the node lives off the land: it surfaces what is already installed rather -than reimplementing a thinner version of it. +real browser, and computer use needs a real display with real input, so the +browser, Xvnc, the fonts, and the software GL renderer stay in the image either +way — and those are the bulk of the cost. Openbox, tint2, and the rest of the +desktop shell add roughly 22 MiB on top of components already being paid for. +Given that, the node lives off the land: it surfaces what is already installed +rather than reimplementing a thinner version of it. ## Node commands @@ -292,9 +293,12 @@ The desktop opens at . Useful overrides include: ```bash PORT=8080 RESOLUTION=1600x900 make up +PLATFORM=linux/arm64 make build DOCKER=podman make up ``` +The Makefile selects `linux/amd64` or `linux/arm64` from the host by default. + `make stop` removes the node container but preserves its named volumes. ## Pinned components @@ -308,9 +312,12 @@ DOCKER=podman make up | Codex ACP adapter | `1.1.7` | | Claude ACP adapter | `0.62.0` | -The Buzz `.deb` and Goose archive are SHA-256 verified during the image build. -Only the required headless Buzz binaries are extracted from the `.deb`; the -package and its desktop application are not installed. +On AMD64, the Buzz `.deb` and Goose archive are SHA-256 verified during the +image build. Only the required headless Buzz binaries are extracted from the +`.deb`; the package and its desktop application are not installed. Upstream +does not publish a Linux ARM64 package, so ARM64 builds compile only those +headless tools from the pinned tag after verifying its exact Git commit. The +ARM64 Goose archive is independently SHA-256 verified. ## Persistence @@ -329,8 +336,13 @@ Buzznode is a trusted, single-user workstation: - the saved agent private key can act as that agent; - KasmVNC browser authentication and TLS are disabled; - the `agent` user has passwordless sudo; -- coding agents can operate on `/workspace`; and -- browser sessions and agent credentials persist in volumes. +- coding agents can operate on `/workspace`; +- browser sessions and agent credentials persist in volumes; and +- Codex runs with `sandbox_mode = "danger-full-access"`, because its bubblewrap + sandbox cannot create a user namespace inside a container, so no sandbox mode + is enforceable here whatever is configured. The setting states what is true + rather than implying a boundary that does not exist; the boundary is the + container. Override with `BUZZNODE_CODEX_SANDBOX_MODE`. The provided Makefile binds the desktop to `127.0.0.1`. Keep that default, or put Buzznode behind authentication, TLS, and suitable network controls. Never diff --git a/RELEASES.md b/RELEASES.md index 0b4af53..809f668 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -22,8 +22,12 @@ Existing tags and releases are never replaced. Stable releases publish `vX.Y.Z`, `X.Y.Z`, `X.Y`, and `latest`. Prereleases publish versioned tags without moving `latest`. -The image is OCI-compatible and currently targets `linux/amd64`, because the -upstream Buzz desktop package is only available for that architecture. +Each tag is a multi-architecture image supporting `linux/amd64` and +`linux/arm64`. Docker selects the matching image automatically. CI builds and +smoke-tests each architecture on a native GitHub-hosted runner before the +release workflow combines their digests into one manifest. Upstream publishes +its Linux package only for AMD64, so the ARM64 image builds the same pinned +headless Buzz tools from the exact tagged source commit. After the first publication, make the GHCR package public in GitHub package settings if anonymous pulls should be allowed. diff --git a/VERSION b/VERSION index 6e8bf73..0ea3a94 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.1.0 +0.2.0 diff --git a/browser/index.html b/browser/index.html index 33d8e5f..1f02467 100644 --- a/browser/index.html +++ b/browser/index.html @@ -3,13 +3,12 @@ - Buzznode + Buzznode Browser -
-

Buzznode

-

- This is a persistent desktop for one agent connected to an existing Buzz - workspace. -

-

- Open a terminal and run buzznode setup to connect it, or - buzznode status to inspect it. -

-
+
+██████╗ ██╗   ██╗███████╗███████╗███╗   ██╗ ██████╗ ██████╗ ███████╗
+██╔══██╗██║   ██║╚══███╔╝╚══███╔╝████╗  ██║██╔═══██╗██╔══██╗██╔════╝
+██████╔╝██║   ██║  ███╔╝   ███╔╝ ██╔██╗ ██║██║   ██║██║  ██║█████╗
+██╔══██╗██║   ██║ ███╔╝   ███╔╝  ██║╚██╗██║██║   ██║██║  ██║██╔══╝
+██████╔╝╚██████╔╝███████╗███████╗██║ ╚████║╚██████╔╝██████╔╝███████╗
+╚═════╝  ╚═════╝ ╚══════╝╚══════╝╚═╝  ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝
diff --git a/gtk/Buzznode/gtk-3.0/gtk.css b/gtk/Buzznode/gtk-3.0/gtk.css new file mode 100644 index 0000000..7910cdc --- /dev/null +++ b/gtk/Buzznode/gtk-3.0/gtk.css @@ -0,0 +1,121 @@ +@define-color theme_bg_color #000000; +@define-color theme_fg_color #edf2f2; +@define-color theme_base_color #000000; +@define-color theme_text_color #edf2f2; +@define-color theme_selected_bg_color #2b2b0b; +@define-color theme_selected_fg_color #ffffff; +@define-color insensitive_bg_color #050707; +@define-color insensitive_fg_color #778282; +@define-color borders #172020; + +* { + color: #edf2f2; + border-color: #172020; + caret-color: #d7d72e; +} + +window, +.background, +headerbar, +menubar, +toolbar { + background-color: #000000; + color: #edf2f2; +} + +/* + * Chrome uses GTK's client-side decoration node for its focused window edge, + * even though it draws the tab strip and caption buttons itself. + */ +window.background.csd decoration, +window.background.solid-csd decoration { + border: 1px solid #172020; + border-radius: 0; + box-shadow: none; +} + +window.background.csd decoration:not(:backdrop), +window.background.solid-csd decoration:not(:backdrop) { + border-color: #ffffff; +} + +/* + * Keep Chrome's window controls at the right edge. The fallback GTK padding + * moves the group too far inward. + */ +headerbar.header-bar.titlebar { + border-radius: 0; + padding-left: 0; + padding-right: 4px; +} + +headerbar.header-bar.titlebar windowcontrols button.titlebutton { + background-color: transparent; + color: #d3dae3; + box-shadow: none; +} + +headerbar.header-bar.titlebar windowcontrols button.titlebutton:backdrop { + color: #1f2328; +} + +headerbar.header-bar.titlebar windowcontrols button.titlebutton:hover { + background-color: transparent; + color: #afb8c5; +} + +headerbar.header-bar.titlebar windowcontrols button.titlebutton:active { + background-color: transparent; + color: #dc143c; +} + +headerbar.header-bar.titlebar windowcontrols button.titlebutton:disabled { + color: #000000; +} + +popover, +popover.background, +popover.background.menu, +menu, +.menu { + background-color: #020303; + color: #edf2f2; +} + +menuitem, +modelbutton { + background-color: transparent; + color: #edf2f2; +} + +menuitem:hover, +menuitem:focus, +modelbutton:hover, +modelbutton:focus { + background-color: #101716; +} + +entry, +textview, +textview text { + background-color: #000000; + color: #edf2f2; +} + +button { + background-color: #050707; + color: #edf2f2; +} + +button:hover, +button:focus { + background-color: #101716; +} + +separator { + background-color: #172020; +} + +:disabled { + color: #778282; +} diff --git a/gtk/Buzznode/gtk-3.0/settings.ini b/gtk/Buzznode/gtk-3.0/settings.ini new file mode 100644 index 0000000..699fa7a --- /dev/null +++ b/gtk/Buzznode/gtk-3.0/settings.ini @@ -0,0 +1,2 @@ +[Settings] +gtk-font-name = Noto Sans 9 diff --git a/gtk/generate-resource-overlay.py b/gtk/generate-resource-overlay.py new file mode 100644 index 0000000..9c2429e --- /dev/null +++ b/gtk/generate-resource-overlay.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""Generate GTK symbolic PNG resources from the Openbox XBM controls.""" + +from __future__ import annotations + +import argparse +import re +import struct +import zlib +from pathlib import Path + + +CONTROL_SOURCES = { + "window-minimize-symbolic.symbolic.png": "iconify.xbm", + "window-maximize-symbolic.symbolic.png": "max.xbm", + "window-restore-symbolic.symbolic.png": "max_toggled.xbm", + "window-close-symbolic.symbolic.png": "close.xbm", +} +PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n" + + +def parse_xbm(path: Path) -> tuple[int, int, bytes]: + source = path.read_text(encoding="utf-8") + width_match = re.search(r"#define\s+\w+_width\s+(\d+)", source) + height_match = re.search(r"#define\s+\w+_height\s+(\d+)", source) + bits_match = re.search(r"\{([^}]*)\}", source, re.DOTALL) + if not width_match or not height_match or not bits_match: + raise ValueError(f"Invalid XBM control: {path}") + + width = int(width_match.group(1)) + height = int(height_match.group(1)) + data = bytes( + int(value, 16) + for value in re.findall(r"0x([0-9a-fA-F]+)", bits_match.group(1)) + ) + expected_size = ((width + 7) // 8) * height + if len(data) != expected_size: + raise ValueError( + f"{path} contains {len(data)} bytes; expected {expected_size}" + ) + return width, height, data + + +def png_chunk(kind: bytes, data: bytes) -> bytes: + return ( + struct.pack(">I", len(data)) + + kind + + data + + struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF) + ) + + +def render_symbolic_png(width: int, height: int, bits: bytes) -> bytes: + canvas_size = 16 + offset_x = (canvas_size - width) // 2 + offset_y = (canvas_size - height) // 2 + bytes_per_row = (width + 7) // 8 + rows = [] + + for canvas_y in range(canvas_size): + row = bytearray([0]) + for canvas_x in range(canvas_size): + source_x = canvas_x - offset_x + source_y = canvas_y - offset_y + visible = False + if 0 <= source_x < width and 0 <= source_y < height: + source_byte = bits[source_y * bytes_per_row + source_x // 8] + visible = bool(source_byte & (1 << (source_x % 8))) + row.extend((0, 0, 0, 255 if visible else 0)) + rows.append(bytes(row)) + + header = struct.pack(">IIBBBBB", canvas_size, canvas_size, 8, 6, 0, 0, 0) + return ( + PNG_SIGNATURE + + png_chunk(b"IHDR", header) + + png_chunk(b"IDAT", zlib.compress(b"".join(rows), level=9)) + + png_chunk(b"IEND", b"") + ) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("openbox_theme", type=Path) + parser.add_argument("overlay_root", type=Path) + args = parser.parse_args() + + output_dir = args.overlay_root / "icons/16x16/status" + output_dir.mkdir(parents=True, exist_ok=True) + for output_name, source_name in CONTROL_SOURCES.items(): + width, height, bits = parse_xbm(args.openbox_theme / source_name) + (output_dir / output_name).write_bytes( + render_symbolic_png(width, height, bits) + ) + + +if __name__ == "__main__": + main() diff --git a/init.sh b/init.sh index f77c9aa..c8562d2 100755 --- a/init.sh +++ b/init.sh @@ -76,6 +76,72 @@ if getent group ssl-cert >/dev/null 2>&1; then usermod -a -G ssl-cert agent fi +# Codex and Claude Code each prompt once per directory before working in it +# ("Do you trust the contents of this directory?"). `buzznode launch` runs the +# harness unattended - it does `cd /workspace` and execs buzz-acp with +# codex-acp, backgrounded into a log file - so there is nobody present to +# answer, and the agent would sit on the prompt with the reason buried in the +# log. codex-acp consults trust_level, so record the decision at boot instead. +# +# This grants no access the harness is not already started with. Set +# BUZZNODE_TRUST_WORKSPACE=false to leave both prompts in place. +harness_workdir="${BUZZNODE_HARNESS_WORKDIR:-/workspace}" +codex_config="$HOME/.codex/config.toml" + +# Codex sandboxes the commands it runs with bubblewrap, and warns when it has to +# fall back to its bundled copy. Neither copy can work here: the container blocks +# unprivileged user namespaces, so bwrap cannot create one, and installing the +# distro package only adds a second binary that fails the same way. Declare the +# mode that matches reality rather than leaving a config that implies an +# isolation boundary which is not there - the boundary is the container itself. +# +# Set BUZZNODE_CODEX_SANDBOX_MODE to read-only or workspace-write to choose a +# different mode, or to an empty value to leave the setting out entirely. +codex_sandbox_mode="${BUZZNODE_CODEX_SANDBOX_MODE-danger-full-access}" +if [ -n "$codex_sandbox_mode" ] && + ! grep -Eq '^sandbox_mode *=' "$codex_config" 2>/dev/null; then + codex_sandbox_tmp="$(mktemp)" + # Prepended, not appended: sandbox_mode is a top-level key, and TOML assigns + # any key following a [table] header to that table. The trust block below + # writes [projects."..."] tables, so appending would quietly turn this into a + # per-project setting instead of a global one. + { + printf 'sandbox_mode = "%s"\n\n' "$codex_sandbox_mode" + [ -s "$codex_config" ] && cat "$codex_config" + } > "$codex_sandbox_tmp" + install -m 0600 -o agent -g agent "$codex_sandbox_tmp" "$codex_config" + rm -f "$codex_sandbox_tmp" + echo "[buzznode] set Codex sandbox_mode=$codex_sandbox_mode" \ + "(no usable bubblewrap in a container)" +fi + +if [ "${BUZZNODE_TRUST_WORKSPACE:-true}" = "true" ]; then + if ! grep -Fq "[projects.\"$harness_workdir\"]" "$codex_config" 2>/dev/null; then + printf '\n[projects."%s"]\ntrust_level = "trusted"\n' \ + "$harness_workdir" >> "$codex_config" + chown agent:agent "$codex_config" + echo "[buzznode] recorded $harness_workdir as trusted for Codex" + fi + + claude_config="$HOME/.claude.json" + if ! jq -e --arg dir "$harness_workdir" \ + '.projects[$dir].hasTrustDialogAccepted == true' \ + "$claude_config" >/dev/null 2>&1; then + [ -s "$claude_config" ] || echo '{}' > "$claude_config" + claude_trust_tmp="$(mktemp)" + # Leave the file untouched if it is not valid JSON rather than + # replacing a config the operator may have hand-written. + if jq --arg dir "$harness_workdir" \ + '.projects[$dir].hasTrustDialogAccepted = true' \ + "$claude_config" > "$claude_trust_tmp" 2>/dev/null; then + install -m 0600 -o agent -g agent \ + "$claude_trust_tmp" "$claude_config" + echo "[buzznode] recorded $harness_workdir as trusted for Claude Code" + fi + rm -f "$claude_trust_tmp" + fi +fi + # Use a GPU only when the host exposes a render node *and* the desktop user can # open it; otherwise keep software rendering. A passed-through node is normally # root:render 0660 and the host's render group does not exist in this image, so diff --git a/openbox/autostart b/openbox/autostart index a61cb20..8872311 100755 --- a/openbox/autostart +++ b/openbox/autostart @@ -7,7 +7,7 @@ if [ ! -f "$HOME/.config/mimeapps.list" ]; then mkdir -p "$HOME/.local/share/applications" "$HOME/.config" cat > "$HOME/.local/share/applications/chromium-buzznode.desktop" <<'CHROMEDESKTOP' [Desktop Entry] -Name=Chrome +Name=Browser Exec=chromium %u Type=Application MimeType=x-scheme-handler/http;x-scheme-handler/https;text/html; diff --git a/openbox/menu.xml b/openbox/menu.xml index d2a9770..b481089 100644 --- a/openbox/menu.xml +++ b/openbox/menu.xml @@ -23,7 +23,7 @@
- + chromium file:///opt/browser/index.html diff --git a/openbox/theme/themerc b/openbox/theme/themerc index 20f2425..3d7e186 100644 --- a/openbox/theme/themerc +++ b/openbox/theme/themerc @@ -20,9 +20,21 @@ ### WINDOW border.width: 1 -window.client.padding.width: 0 -window.client.padding.height: 0 -window.handle.width: 4 +# Grab margin. Openbox has no separate resize-sensitivity setting: the areas you +# can grab are exactly the areas it draws, and with the bottom handle gone the +# frame offered 1px to grab at the bottom against a 28px titlebar - the bottom +# corners were nearly unhittable. Client padding is the one knob that adds frame +# without widening the visible border: it inserts frame around the client and +# paints it in the frame background, so nothing new is drawn while the grabbable +# ring goes from 1px to 7px. Measured via _NET_FRAME_EXTENTS: 1,1,28,1 at 0 +# becomes 7,7,34,7 at 6. +window.client.padding.width: 6 +window.client.padding.height: 6 +# No bottom handle. It renders as a second line under the client area with a +# resize grip boxed off at each end, which reads as clutter. Resizing stays +# available through the window edges and corners and through Alt+right-drag +# anywhere on the frame (see rc.xml). +window.handle.width: 0 padding.width: 6 padding.height: 5 diff --git a/shell/bashrc b/shell/bashrc index 5d55805..b75cdd9 100644 --- a/shell/bashrc +++ b/shell/bashrc @@ -37,3 +37,19 @@ if [ -f "$HOME/.config/buzznode/environment" ]; then # shellcheck disable=SC1091 source "$HOME/.config/buzznode/environment" fi + +# Start in the workspace rather than the home directory. +# +# This has to happen here rather than anywhere earlier in the launch chain: +# Openbox chdirs to $HOME when it starts, whatever directory it was started +# from, and hands that to everything it launches. So the session working +# directory cannot be set once for the desktop - terminals arrive in $HOME no +# matter what the entrypoint or xstartup does. The shell is the one place every +# terminal passes through regardless of which launcher opened it. +# +# Only when the shell landed in $HOME, which is the inherited default. A shell +# started anywhere else is left where it is, so this never overrides a directory +# somebody chose on purpose. +if [ -n "${PS1:-}" ] && [ "$PWD" = "$HOME" ] && [ -d /workspace ]; then + cd /workspace || true +fi diff --git a/shell/chromium b/shell/chromium index 6fde25f..627a1cd 100755 --- a/shell/chromium +++ b/shell/chromium @@ -1,11 +1,11 @@ #!/bin/bash -# chromium - wrapper that launches Google Chrome with container-safe flags. +# chromium - wrapper that launches the available Chromium-family browser. # Placed in /usr/local/bin to override any system chromium and provide a # single entry point for menus, xdg-open, kitty, and CLI tools. # Only use the GPU when a render node is present *and* this user can open it. -# With one Chrome can composite and rasterize on the GPU, which removes the -# largest source of repaint work on this desktop. A passed-through node is +# With one the browser can composite and rasterize on the GPU, which removes +# the largest source of repaint work on this desktop. A passed-through node is # normally root:render 0660 and this image has no matching group, so testing # for presence alone points Chrome's GPU process at a device it cannot open. gpu_args=(--disable-gpu --disable-software-rasterizer) @@ -16,7 +16,16 @@ for node in /dev/dri/renderD*; do fi done -exec /opt/google/chrome/google-chrome \ +if [ -x /opt/google/chrome/google-chrome ]; then + browser=/opt/google/chrome/google-chrome +elif [ -x /usr/bin/chromium ]; then + browser=/usr/bin/chromium +else + echo "No supported Chromium-family browser is installed." >&2 + exit 1 +fi + +exec "$browser" \ --no-sandbox \ --test-type \ "${gpu_args[@]}" \ diff --git a/tests/smoke-container.sh b/tests/smoke-container.sh new file mode 100644 index 0000000..0148948 --- /dev/null +++ b/tests/smoke-container.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash + +set -euo pipefail + +container="${1:?usage: smoke-container.sh CONTAINER [ARCH]}" +expected_arch="${2:-}" +docker="${DOCKER:-docker}" + +actual_arch="$("$docker" exec "$container" dpkg --print-architecture)" +if [ -n "$expected_arch" ] && [ "$actual_arch" != "$expected_arch" ]; then + echo "Expected $expected_arch container, got $actual_arch" >&2 + exit 1 +fi + +"$docker" exec "$container" bash -ec ' + for command in agent-runtime-login buzznode buzz buzz-acp \ + buzz-agent buzz-dev-mcp git-credential-nostr \ + codex codex-acp claude claude-agent-acp goose chromium; do + command -v "$command" >/dev/null + done + + case "$(dpkg --print-architecture)" in + amd64) + test -x /opt/google/chrome/google-chrome + test ! -x /usr/bin/chromium + ;; + arm64) + test -x /usr/bin/chromium + test ! -x /opt/google/chrome/google-chrome + ;; + *) + exit 1 + ;; + esac + + buzz --help >/dev/null + buzz-acp --help >/dev/null + goose --version + chromium --version + ! command -v buzz-desktop >/dev/null + ! command -v buzz-relay >/dev/null + ! command -v postgres >/dev/null + curl -fsS http://127.0.0.1:6901/ >/dev/null +' + +# Prove that the architecture's actual headed browser reaches the desktop, not +# merely that its executable loader and --version path work. +"$docker" exec --detach \ + --user agent \ + --env DISPLAY=:1 \ + --env HOME=/home/buzznode \ + "$container" \ + chromium file:///opt/browser/index.html + +browser_ready=false +for attempt in $(seq 1 30); do + if "$docker" exec \ + --user agent \ + --env DISPLAY=:1 \ + "$container" \ + xdotool search --onlyvisible --name 'Buzznode Browser' \ + >/dev/null 2>&1; then + browser_ready=true + break + fi + sleep 1 +done + +if [ "$browser_ready" != "true" ]; then + echo "The $actual_arch browser did not create a visible desktop window." >&2 + "$docker" exec "$container" ps aux >&2 || true + exit 1 +fi + +"$docker" exec \ + --user agent \ + --env DISPLAY=:1 \ + "$container" \ + scrot /tmp/buzznode-browser-smoke.png +"$docker" exec "$container" test -s /tmp/buzznode-browser-smoke.png +"$docker" exec "$container" rm -f /tmp/buzznode-browser-smoke.png + +echo "Buzznode container and headed browser passed on linux/$actual_arch." diff --git a/tests/test-agent-runtime-login.sh b/tests/test-agent-runtime-login.sh index a74fd1c..f206b35 100755 --- a/tests/test-agent-runtime-login.sh +++ b/tests/test-agent-runtime-login.sh @@ -73,4 +73,15 @@ if run_helper codex unknown >/dev/null 2>&1; then exit 1 fi +# Codex and Claude Code prompt once per directory before working in it, and +# codex-acp consults the same trust_level. `buzznode launch` starts the harness +# unattended in the workspace, so that prompt has to be settled at boot or the +# agent stops with the reason buried in its log. +grep -Fq 'BUZZNODE_TRUST_WORKSPACE' "$project_dir/init.sh" +grep -Fq 'trust_level = "trusted"' "$project_dir/init.sh" +grep -Fq 'hasTrustDialogAccepted' "$project_dir/init.sh" +# The directory trusted at boot must be the one the harness is launched in. +grep -Fq 'BUZZNODE_HARNESS_WORKDIR:-/workspace' "$project_dir/init.sh" +grep -Fq 'cd /workspace' "$project_dir/shell/buzznode" + echo "Agent runtime login tests passed." diff --git a/tests/test-desktop-theme.sh b/tests/test-desktop-theme.sh new file mode 100644 index 0000000..85767a4 --- /dev/null +++ b/tests/test-desktop-theme.sh @@ -0,0 +1,125 @@ +#!/bin/bash + +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +overlay_test_dir="$(mktemp -d)" +trap 'rm -rf "$overlay_test_dir"' EXIT + +grep -Fq 'background: #000;' "$project_dir/browser/index.html" +grep -Fq 'Buzznode Browser' "$project_dir/browser/index.html" +grep -Fq '██████╗ ██╗ ██╗███████╗███████╗███╗' \ + "$project_dir/browser/index.html" +if grep -Fq '

Buzznode

' "$project_dir/browser/index.html"; then + echo "The obsolete browser welcome card is still present." >&2 + exit 1 +fi + +grep -Fq 'ENV GTK_THEME=Buzznode' "$project_dir/Dockerfile" +grep -Fq \ + 'ENV G_RESOURCE_OVERLAYS=/org/gtk/libgtk=/usr/share/buzznode/gtk-overlay' \ + "$project_dir/Dockerfile" +grep -Fq \ + 'COPY gtk/Buzznode /usr/share/themes/Buzznode' \ + "$project_dir/Dockerfile" +grep -Fq \ + 'COPY gtk/generate-resource-overlay.py /tmp/generate-gtk-resource-overlay.py' \ + "$project_dir/Dockerfile" + +python3 "$project_dir/gtk/generate-resource-overlay.py" \ + "$project_dir/openbox/theme" "$overlay_test_dir" +for control in minimize maximize restore close; do + control_path="$overlay_test_dir/icons/16x16/status/window-${control}-symbolic.symbolic.png" + test -s "$control_path" + python3 -c \ + 'import pathlib, sys; assert pathlib.Path(sys.argv[1]).read_bytes().startswith(b"\x89PNG\r\n\x1a\n")' \ + "$control_path" +done + +grep -Fq '"system_theme": 1' "$project_dir/Dockerfile" +grep -Fq 'for config_dir in google-chrome chromium' \ + "$project_dir/Dockerfile" +grep -Fq '/etc/chromium/policies/managed/buzznode-policy.json' \ + "$project_dir/Dockerfile" +grep -Fq 'browser=/opt/google/chrome/google-chrome' \ + "$project_dir/shell/chromium" +grep -Fq 'browser=/usr/bin/chromium' \ + "$project_dir/shell/chromium" +grep -Fq 'exec "$browser"' \ + "$project_dir/shell/chromium" +grep -Fq 'popover.background.menu' \ + "$project_dir/gtk/Buzznode/gtk-3.0/gtk.css" +grep -Fq 'background-color: #020303;' \ + "$project_dir/gtk/Buzznode/gtk-3.0/gtk.css" +grep -Fq 'window.background.csd decoration' \ + "$project_dir/gtk/Buzznode/gtk-3.0/gtk.css" +grep -Fq 'decoration:not(:backdrop)' \ + "$project_dir/gtk/Buzznode/gtk-3.0/gtk.css" +grep -Fq 'headerbar.header-bar.titlebar' \ + "$project_dir/gtk/Buzznode/gtk-3.0/gtk.css" +grep -Fq 'border-radius: 0;' \ + "$project_dir/gtk/Buzznode/gtk-3.0/gtk.css" +grep -Fq 'padding-right: 4px;' \ + "$project_dir/gtk/Buzznode/gtk-3.0/gtk.css" +grep -Fq 'button.titlebutton:backdrop' \ + "$project_dir/gtk/Buzznode/gtk-3.0/gtk.css" +grep -Fq 'caret-color: #d7d72e;' \ + "$project_dir/gtk/Buzznode/gtk-3.0/gtk.css" +grep -Fq 'color: #dc143c;' \ + "$project_dir/gtk/Buzznode/gtk-3.0/gtk.css" +grep -Fxq 'gtk-font-name = Noto Sans 9' \ + "$project_dir/gtk/Buzznode/gtk-3.0/settings.ini" +test "$(grep -Fc 'Noto Sans' "$project_dir/openbox/rc.xml")" -eq 6 +test "$(grep -Fc '9' "$project_dir/openbox/rc.xml")" -eq 6 + +if grep -Fq '"BrowserThemeColor"' "$project_dir/Dockerfile"; then + echo "BrowserThemeColor still overrides the GTK Chrome theme." >&2 + exit 1 +fi +if grep -Fq -- '--pack-extension=' "$project_dir/Dockerfile"; then + echo "The obsolete Chrome extension theme is still packaged." >&2 + exit 1 +fi + +grep -Fq 'amd64|arm64)' "$project_dir/Dockerfile" +grep -Fq 'yq_linux_${arch}' "$project_dir/Dockerfile" +grep -Fq 'kasmvncserver_noble_${KASMVNC_VERSION}_${arch}.deb' \ + "$project_dir/Dockerfile" +grep -Fq 'google-chrome-stable_current_amd64.deb' \ + "$project_dir/Dockerfile" +grep -Fq "'deb [arch=arm64 signed-by=/etc/apt/keyrings/debian-archive-key-12.asc] https://deb.debian.org/debian bookworm main'" \ + "$project_dir/Dockerfile" +grep -Fq 'apt-get install -y --no-install-recommends chromium' \ + "$project_dir/Dockerfile" +grep -Fq 'git clone --branch "v${BUZZ_VERSION}" --depth 1' \ + "$project_dir/Dockerfile" +grep -Fq 'FROM rust:1.95-bookworm AS buzz-tools' \ + "$project_dir/Dockerfile" +grep -Fq 'test "$(git rev-parse HEAD)" = "$BUZZ_SOURCE_SHA"' \ + "$project_dir/Dockerfile" +grep -Fq 'arm64) goose_arch=aarch64' "$project_dir/Dockerfile" +if grep -Fq 'Buzznode currently supports linux/amd64 only' \ + "$project_dir/Dockerfile"; then + echo "The Dockerfile still rejects ARM64 builds." >&2 + exit 1 +fi + +grep -Fq 'TARGETARCH ?= $(word 2,$(subst /, ,$(PLATFORM)))' \ + "$project_dir/Makefile" +grep -Fq -- '--build-arg "TARGETARCH=$(TARGETARCH)"' \ + "$project_dir/Makefile" +grep -Fq 'PLATFORM ?= linux/$(NATIVE_ARCH)' \ + "$project_dir/Makefile" + +ci_workflow="$project_dir/.github/workflows/ci.yaml" +release_workflow="$project_dir/.github/workflows/release.yaml" +for workflow in "$ci_workflow" "$release_workflow"; do + grep -Fq 'platform: linux/amd64' "$workflow" + grep -Fq 'platform: linux/arm64' "$workflow" + grep -Fq 'runner: ubuntu-24.04-arm' "$workflow" +done +grep -Fq 'bash tests/smoke-container.sh "$container" "$ARCH"' \ + "$ci_workflow" +grep -Fq 'push-by-digest=true' "$release_workflow" +grep -Fq 'merge-multiple: true' "$release_workflow" +grep -Fq 'docker buildx imagetools create' "$release_workflow" diff --git a/tools/size-report.sh b/tools/size-report.sh index 3b1cbae..5bcef26 100755 --- a/tools/size-report.sh +++ b/tools/size-report.sh @@ -36,7 +36,7 @@ echo # The desktop top-levels. WebKit/GTK are Buzz Desktop's runtime in Buzzbox and # absent from Buzznode; the closure handles either case. GRAPHICAL_TOPLEVEL=" -google-chrome-stable kasmvncserver +google-chrome-stable chromium chromium-common kasmvncserver xterm dbus-x11 x11-utils x11-xserver-utils xorg scrot openbox obconf tint2 kitty feh picom xdotool wmctrl xclip fonts-noto fonts-noto-color-emoji xfonts-base @@ -68,7 +68,7 @@ echo "-------------------------" awk 'NR==FNR {want[$1]=1; next} want[$1] { p=$1; s=$2 - if (p ~ /^google-chrome/) c="Chrome browser" + if (p ~ /^google-chrome/ || p ~ /^chromium/) c="Chromium-family browser" else if (p ~ /^libwebkit|javascriptcore/) c="WebKitGTK (Buzz Desktop runtime)" # Buzzbox installs the Buzz .deb whole, so the GUI binary drags the # package (headless tools included) into the graphical closure.