diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..5b65dd2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,17 @@ +.git +.env +rootfs.ext4 + +# Nothing below is COPYed by the Dockerfile. Excluding it keeps the build +# context small and makes it structurally impossible for a docs, test, or CI +# edit to reach the builder at all. +.github +.gitignore +CHANGELOG.md +IMAGE-SIZE.md +Makefile +README.md +RELEASES.md +VERSION +tests +tools diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..3ba75ce --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,127 @@ +name: CI + +# Building and smoke-testing the image costs many minutes, so prose-only +# changes skip it. The ignore list is deliberately explicit rather than a +# `*.md` glob: VERSION and CHANGELOG.md must keep triggering CI. They are the +# release intent, a release commit usually touches nothing else, and +# tag-release.yaml only fires after a successful CI run on main. +# +# This is an ignore list, not a `paths` allowlist, so that a new directory +# nobody remembered to register still gets built and tested. +on: + push: + branches: + - main + - next + tags-ignore: + - 'v*' + paths-ignore: + - 'README.md' + - 'RELEASES.md' + - 'IMAGE-SIZE.md' + - '.gitignore' + pull_request: + branches: + - main + - next + paths-ignore: + - 'README.md' + - 'RELEASES.md' + - 'IMAGE-SIZE.md' + - '.gitignore' + +permissions: + contents: read + +jobs: + build: + name: Build and smoke test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Validate release metadata + shell: bash + run: | + set -euo pipefail + + VERSION="$(tr -d '[:space:]' < VERSION)" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "VERSION is not valid semantic versioning: $VERSION" >&2 + exit 1 + fi + + if ! grep -q "^## \\[$VERSION\\]" CHANGELOG.md; then + echo "CHANGELOG.md has no section for $VERSION" >&2 + exit 1 + fi + + # Seconds, and needs no Docker. Running it before the build means a typo + # in a shell script or a Makefile pin that drifted from the Dockerfile + # fails here instead of after a full image build. + - name: Validate scripts and pinned metadata + run: make check + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Build image + uses: docker/build-push-action@v7 + with: + context: . + load: true + platforms: linux/amd64 + push: false + tags: pdparchitect/buzznode:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Smoke test + shell: bash + run: | + set -euo pipefail + + container="buzznode-ci" + cleanup() { + docker rm --force "$container" >/dev/null 2>&1 || true + } + trap cleanup EXIT + + docker run --detach \ + --name "$container" \ + --platform linux/amd64 \ + --shm-size 1g \ + --publish 127.0.0.1:16903:6901 \ + pdparchitect/buzznode:ci + + ready=false + for attempt in $(seq 1 60); do + if curl --fail --silent http://127.0.0.1:16903/index.html \ + >/dev/null; then + ready=true + break + fi + sleep 2 + done + + if [ "$ready" != "true" ]; then + echo "Buzznode did not become ready within 120 seconds" >&2 + docker logs --tail 150 "$container" >&2 || 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 + ' + + echo "Buzznode is ready with its desktop and agent runtimes" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..fe85872 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,126 @@ +name: Release + +on: + push: + tags: + - 'v*' + # Dispatched by tag-release.yaml because tags pushed with GITHUB_TOKEN do not + # trigger another workflow. + workflow_dispatch: + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +permissions: + contents: write + packages: write + +jobs: + validate: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Validate tag and changelog + shell: bash + run: | + set -euo pipefail + + VERSION="$(tr -d '[:space:]' < VERSION)" + if [[ "$GITHUB_REF_NAME" != "v${VERSION}" ]]; then + echo "Tag $GITHUB_REF_NAME does not match VERSION v${VERSION}" >&2 + exit 1 + fi + + if ! grep -q "^## \\[$VERSION\\]" CHANGELOG.md; then + echo "CHANGELOG.md has no section for $VERSION" >&2 + exit 1 + fi + + publish: + needs: validate + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v5 + + - 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 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + latest=false + tags: | + type=raw,value=${{ github.ref_name }} + 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: Generate release notes + id: notes + shell: bash + env: + IMAGE: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + DIGEST: ${{ steps.build.outputs.digest }} + run: | + set -euo pipefail + + VERSION="${GITHUB_REF_NAME#v}" + { + echo "content<> "$GITHUB_OUTPUT" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v3 + with: + body: ${{ steps.notes.outputs.content }} + prerelease: ${{ contains(github.ref_name, '-') }} diff --git a/.github/workflows/tag-release.yaml b/.github/workflows/tag-release.yaml new file mode 100644 index 0000000..0942b6f --- /dev/null +++ b/.github/workflows/tag-release.yaml @@ -0,0 +1,86 @@ +# Create a release tag only after CI succeeds on main. +# +# The VERSION file is the release intent. If its matching tag does not exist, +# this workflow creates an annotated tag at the exact CI-tested commit and +# dispatches the release workflow at that tag. +name: Tag Release + +on: + workflow_run: + workflows: + - CI + types: + - completed + +jobs: + tag: + if: >- + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main' + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + + steps: + - name: Checkout tested commit + uses: actions/checkout@v5 + with: + ref: ${{ github.event.workflow_run.head_sha }} + fetch-depth: 0 + + - name: Validate release metadata + id: version + shell: bash + run: | + set -euo pipefail + + VERSION="$(tr -d '[:space:]' < VERSION)" + if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]]; then + echo "VERSION is not valid semantic versioning: $VERSION" >&2 + exit 1 + fi + + if ! grep -q "^## \\[$VERSION\\]" CHANGELOG.md; then + echo "CHANGELOG.md has no section for $VERSION" >&2 + exit 1 + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check whether tag exists + id: tag + shell: bash + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + if git rev-parse "refs/tags/v${VERSION}" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create and push tag + if: steps.tag.outputs.exists == 'false' + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git tag -a "v${VERSION}" -m "Release v${VERSION}" + git push origin "v${VERSION}" + + - name: Trigger release workflow + if: steps.tag.outputs.exists == 'false' + uses: actions/github-script@v8 + env: + VERSION: ${{ steps.version.outputs.version }} + with: + script: | + await github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'release.yaml', + ref: `v${process.env.VERSION}` + }) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4da200a --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.env +rootfs.ext4 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b369395 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +All notable changes to Buzznode are documented here, following +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and +[Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [0.1.0] - 2026-07-26 + +### Added + +- Create a persistent, browser-accessible Linux computer for one Buzz agent. +- Run that agent through the headless `buzz-acp` harness against an existing + external relay. The image contains no Buzz Desktop, relay, database, or + object store, and has no runtime dependency on any particular Buzz client. +- Add a terminal-first setup wizard for the relay URL, optional API token, and + Codex, Claude Code, or Goose runtime. +- Add agent-identity setup and direct `buzz-acp` lifecycle management. +- Add sensitive Buzzbox enrollment-bundle import so a stopped managed agent's + identity, authorization, relay, and response policy move together. +- Validate the relay URL, private key, owner authorization, and allowlist + before reporting that an enrollment bundle was accepted. +- Add `allowlist` response-policy configuration and validation. +- Add node status, diagnostics, lifecycle, runtime-login, and log commands. +- Route the desktop log menu through `buzznode logs` and keep shell commands on + single lines so Openbox cannot split arguments into unintended commands. +- Add an interactive runtime authentication chooser. Codex offers device code, + desktop browser, API key, and status flows; Claude Code offers subscription, + Console, long-lived setup token, SSO, and status flows. +- Keep setup windows open as normal terminal sessions after confirmation. +- Add ANSI headings, steps, success states, warnings, errors, diagnostics, and + styled prompts to the Buzznode CLI, with `NO_COLOR` support. +- Add optional `BUZZ_NETWORK` support for pairing with an independent Buzzbox + project over a private Docker network. +- Add `make connection-test` for verifying the configured external relay. +- Persist the desktop, workspace, Buzz nest, and agent-runtime state in + dedicated volumes, normalizing ownership once per volume lifetime rather + than on every boot. +- Enable KasmVNC `hw3d` and Chrome's GPU flags only when the desktop user can + actually open the render node, and pass through only `/dev/dri/renderD*` + rather than the whole `/dev/dri` directory, which would also hand over the + `card*` DRM master/modesetting node. The startup log distinguishes an absent + render node from an inaccessible one. +- Add `make size-report` and `tools/size-report.sh`, which measure the + graphical stack's share of the image as an apt dependency closure. +- Document the graphical stack's measured cost, the rationale for keeping it, + and its role as the computer-use substrate in `IMAGE-SIZE.md`. +- Add node-specific shell tests, container smoke checks, and release workflows. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fd63503 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,279 @@ +# syntax=docker/dockerfile:1.7 +# +# 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. + +# ═══════════════════════════════════════════════════════════════════ +# Stage: core - shared runtime/tooling baseline for agent workloads. +# ═══════════════════════════════════════════════════════════════════ +FROM ubuntu:24.04 AS core + +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; } + +# Core tools for the node and its coding-agent runtimes. +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash coreutils curl git openssh-client jq socat wget ca-certificates sudo \ + tar zip unzip file procps openssl gnupg \ + dnsutils iproute2 haveged \ + sqlite3 \ + python3 python3-pip python-is-python3 \ + python3-numpy python3-pandas python3-scipy python3-requests \ + ipython3 \ + vim ripgrep git-lfs \ + && rm -rf /var/lib/apt/lists/* + +# Node.js 24, matching the current Buzz development toolchain. +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \ + apt-get install -y --no-install-recommends nodejs && \ + rm -rf /var/lib/apt/lists/* && \ + node --version && npm --version + +RUN corepack enable && corepack prepare pnpm@10.13.1 --activate && \ + pnpm --version + +# Coding CLIs and the ACP adapters that make them discoverable by Buzz. +ARG CODEX_VERSION=0.145.0 +ARG CLAUDE_CODE_VERSION=2.1.220 +ARG CODEX_ACP_VERSION=1.1.7 +ARG CLAUDE_ACP_VERSION=0.62.0 +RUN npm install -g \ + "@openai/codex@${CODEX_VERSION}" \ + "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" \ + "@agentclientprotocol/codex-acp@${CODEX_ACP_VERSION}" \ + "@agentclientprotocol/claude-agent-acp@${CLAUDE_ACP_VERSION}" && \ + codex --version && \ + claude --version && \ + codex-acp --version && \ + claude-agent-acp --version + +# 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" && \ + 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" \ + -o "$goose_archive" && \ + echo "${GOOSE_ARCHIVE_SHA256} ${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 && \ + rm -rf "$goose_dir" "$goose_archive" && \ + goose --version && \ + goose acp --help >/dev/null + +# 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" \ + -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. +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"; \ + done; \ + rm -rf "$extract_dir" "$buzz_deb"; \ + command -v buzz; \ + command -v buzz-acp; \ + command -v buzz-agent; \ + command -v buzz-dev-mcp; \ + test ! -e /usr/bin/buzz-desktop; \ + test ! -e /usr/local/bin/buzz-desktop + +# Required directories. +RUN mkdir -p /data /outputs /workspace /var/log/buzznode + +# Alias ipython to ipython3 and pip to pip3 for consistency. +RUN ln -sf /usr/bin/ipython3 /usr/bin/ipython && \ + ln -sf /usr/bin/pip3 /usr/bin/pip + +# ═══════════════════════════════════════════════════════════════════ +# Stage: base - desktop UI substrate layered on top of core. +# ═══════════════════════════════════════════════════════════════════ +FROM core AS base + +# KasmVNC supplies its own X server (Xvnc), so the `xorg` metapackage is not +# installed: it would add xserver-xorg-core, input/video drivers, keyboard- +# configuration, and udev/systemd for hardware this container never has. +# `x11-xserver-utils` is skipped for the same reason - its only consumer would +# be the xrdb call in KasmVNC's generated xstartup, and xstartup is replaced +# below with `exec openbox-session`. Together they cost ~90 MiB. +# xauth, xkb-data, and x11-xkb-utils are listed explicitly even though +# kasmvncserver depends on them, so an autoremove can never take them out. +# xfonts-base supplies the core font path Xvnc is started with. +RUN apt-get update && apt-get install -y --no-install-recommends \ + xdg-utils ssl-cert \ + xauth xkb-data x11-xkb-utils xfonts-base \ + xterm dbus-x11 x11-utils \ + scrot \ + openbox obconf tint2 kitty ranger feh picom htop xdotool wmctrl \ + fonts-noto fonts-noto-color-emoji \ + libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 \ + libpango-1.0-0 libasound2t64 libxshmfence1 \ + && rm -rf /var/lib/apt/lists/* + +# Cortile provides optional dynamic tiling on top of Openbox. +ARG CORTILE_VERSION=2.5.2 +RUN tmp_dir="$(mktemp -d)"; \ + curl -fsSL \ + "https://github.com/leukipp/cortile/releases/download/v${CORTILE_VERSION}/cortile_${CORTILE_VERSION}_linux_amd64.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" \ + -o /tmp/kasmvnc.deb && \ + apt-get update && \ + apt-get install -y --no-install-recommends /tmp/kasmvnc.deb && \ + rm -f /tmp/kasmvnc.deb && \ + rm -rf /var/lib/apt/lists/* + +# Docker and GitHub CLIs remain available for coding-agent workflows. Buzznode +# does not require a host Docker socket. +RUN curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor \ + -o /usr/share/keyrings/docker-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu noble stable" \ + > /etc/apt/sources.list.d/docker.list && \ + curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + -o /usr/share/keyrings/githubcli-archive-keyring.gpg && \ + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" \ + > /etc/apt/sources.list.d/github-cli.list && \ + apt-get update && \ + 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 + +# ═══════════════════════════════════════════════════════════════════ +# Stage: buzznode - one persistent desktop connected to an existing Buzz relay. +# ═══════════════════════════════════════════════════════════════════ +FROM base AS buzznode + +RUN if id -u agent >/dev/null 2>&1; then \ + usermod -d /home/buzznode -m agent; \ + elif id -u ubuntu >/dev/null 2>&1; then \ + usermod -l agent -d /home/buzznode -m ubuntu && groupmod -n agent ubuntu; \ + else \ + groupadd --system agent && \ + useradd --system --create-home --home-dir /home/buzznode \ + --gid agent --shell /bin/bash agent; \ + fi && \ + mkdir -p \ + /home/buzznode/.vnc \ + /home/buzznode/.config/buzznode \ + /home/buzznode/.local/share/applications \ + /home/buzznode/.buzz \ + /home/buzznode/.codex \ + /home/buzznode/.claude \ + /workspace \ + /var/log/buzznode && \ + chown -R agent:agent \ + /home/buzznode \ + /workspace \ + /var/log/buzznode + +ENV HOME=/home/buzznode \ + BROWSER=chromium + +RUN echo "agent ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/agent && \ + chmod 0440 /etc/sudoers.d/agent && \ + touch /home/buzznode/.sudo_as_admin_successful /home/buzznode/.hushlogin && \ + chown agent:agent \ + /home/buzznode/.sudo_as_admin_successful \ + /home/buzznode/.hushlogin + +# KasmVNC UI customisation. +COPY kasm/custom.css /usr/share/kasmvnc/www/assets/custom.css +COPY kasm/favicon.svg /usr/share/kasmvnc/www/assets/favicon.svg +COPY kasm/patch.sh /tmp/kasm-patch.sh +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 && \ + 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 + +COPY openbox/rc.xml /etc/xdg/openbox/rc.xml +COPY openbox/menu.xml /etc/xdg/openbox/menu.xml +COPY openbox/autostart /etc/xdg/openbox/autostart +COPY openbox/theme /usr/share/themes/Triste-Crimson/openbox-3 +COPY cortile/cortilectl /usr/local/bin/cortilectl +COPY shell/welcome /usr/local/bin/welcome +COPY shell/chromium /usr/local/bin/chromium +COPY shell/buzznode /usr/local/bin/buzznode +COPY shell/buzznode-panel-status /usr/local/bin/buzznode-panel-status +COPY shell/agent-runtime-login /usr/local/bin/agent-runtime-login +RUN mkdir -p /etc/bash.bashrc.d +COPY shell/bashrc /etc/bash.bashrc.d/buzznode-prompt.sh +COPY browser /opt/browser +COPY tint2/tint2rc /etc/xdg/tint2/tint2rc +RUN chmod +x \ + /etc/xdg/openbox/autostart \ + /usr/local/bin/cortilectl \ + /usr/local/bin/welcome \ + /usr/local/bin/chromium \ + /usr/local/bin/buzznode \ + /usr/local/bin/buzznode-panel-status \ + /usr/local/bin/agent-runtime-login && \ + echo '[ -d /etc/bash.bashrc.d ] && for f in /etc/bash.bashrc.d/*.sh; do . "$f"; done' \ + >> /etc/bash.bashrc + +RUN mkdir -p /usr/share/xsessions && \ + printf '[Desktop Entry]\nName=Openbox\nExec=openbox-session\nType=Application\n' \ + > /usr/share/xsessions/openbox.desktop + +USER agent + +RUN mkdir -p "$HOME/.config/cortile" +COPY --chown=agent:agent cortile/cortile-config.toml /home/buzznode/.config/cortile/config.toml + +RUN printf '#!/bin/bash\nexec openbox-session\n' > "$HOME/.vnc/xstartup" && \ + chmod +x "$HOME/.vnc/xstartup" && \ + touch "$HOME/.vnc/.de-was-selected" && \ + printf 'network:\n ssl:\n require_ssl: false\n websocket_port: 6901\n' \ + > "$HOME/.vnc/kasmvnc.yaml" + +USER root + +COPY init.sh /init +RUN chmod +x /init + +EXPOSE 6901 +WORKDIR /workspace +VOLUME ["/workspace", "/home/buzznode/.config", "/home/buzznode/.local/share", "/home/buzznode/.buzz", "/home/buzznode/.codex", "/home/buzznode/.claude"] + +HEALTHCHECK --interval=10s --timeout=5s --start-period=30s --retries=6 \ + CMD curl -fsS http://127.0.0.1:6901/ >/dev/null || exit 1 + +ENTRYPOINT ["/init"] diff --git a/IMAGE-SIZE.md b/IMAGE-SIZE.md new file mode 100644 index 0000000..b01f022 --- /dev/null +++ b/IMAGE-SIZE.md @@ -0,0 +1,145 @@ +# Image size and the graphical stack + +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. + +Regenerate every number here with: + +```bash +make build +make size-report +``` + +## Measured composition + +`pdparchitect/buzznode:local`, **3.54 GB** (3380 MiB) uncompressed: + +| Layer | Size | +| --------------------------------------- | -----: | +| Codex, Claude Code, and the ACP adapters | 1.31 GB | +| Core apt baseline | 516 MB | +| Desktop apt layer | 466 MB | +| Google Chrome | 441 MB | +| Goose | 299 MB | +| Node.js 24 | 197 MB | +| Docker and GitHub CLIs | 88.6 MB | +| Ubuntu base | 78.1 MB | +| Buzz headless binaries | 70 MB | +| KasmVNC | 35.3 MB | + +The graphical substrate is **899 MiB, or 26.6% of the image**. It is not the +largest contributor: the three coding CLIs alone are larger. + +Measured as a dependency closure — what apt would remove if the desktop +top-level packages were purged — so transitively shared libraries are counted +once and attributed correctly: + +| Component | Size | +| ------------------------------------- | -------: | +| Chrome | 413.6 MiB | +| Mesa and LLVM software GL | 186.1 MiB | +| Fonts and icon themes | 144.3 MiB | +| KasmVNC and its perl dependencies | 36.1 MiB | +| Ghostscript, via openbox/tint2 imlib2 | 26.3 MiB | +| GTK, Pango, GStreamer | 23.8 MiB | +| kitty terminal | 18.8 MiB | +| X11 server and utilities | 7.7 MiB | +| Openbox, tint2, picom | 5.2 MiB | +| Other shared libraries | 37.3 MiB | + +Plus 13 MB of non-package assets: the cortile binary, the patched KasmVNC web +client, the Openbox theme, and the wallpaper. + +**The desktop itself is nearly free.** Openbox, tint2, picom, the X utilities, +and cortile together are about 22 MiB. The weight is Chrome, software GL, and +fonts — three things that exist to make the display *useful*, not to make it +exist. + +## Why a headless node ships a desktop + +Buzznode's agent does not need a desktop to answer a Buzz message. The desktop +is there for three reasons, and the third is the one that matters going +forward. + +**1. The node is meant to be inspectable.** A long-lived agent computer that +cannot be looked at is difficult to trust or debug. The desktop provides a +terminal, a file manager, and a browser against the same filesystem and process +namespace the agent is working in. + +**2. Runtime authentication genuinely requires a browser.** `agent-runtime-login` +drives Codex and Claude Code sign-in. Device-code, Console, and SSO flows all +end at a real browser session, and doing that inside the node keeps the +resulting credentials in the node's own volumes rather than pasted in from +somewhere else. + +**3. Computer use.** This is the forward-looking rationale, and it applies to +Buzznode more than to Buzzbox. + +## Computer use + +The graphical stack is not overhead awaiting removal — it is the substrate for +letting the agent drive a real computer, and Buzznode already contains the +complete toolchain: + +| Capability | Component | Present | +| ------------------- | ------------------------ | ------- | +| Screen capture | `scrot` | yes | +| Input injection | `xdotool` | yes | +| Window management | `wmctrl`, Openbox | yes | +| Target application | Google Chrome | yes | +| Display server | Xvnc, via KasmVNC | yes | +| Human co-observation| KasmVNC in a browser | yes | + +Nothing needs to be added to give the agent a mouse, a keyboard, and eyes. The +same `:1` display that a human watches through KasmVNC is the display an agent +can screenshot and click, which means a human can watch a computer-use session +live and take over mid-task. + +This shapes what is worth trimming. Chrome is the single largest graphical item +at 414 MiB, and it is also precisely the surface a computer-use agent operates. +Fonts are 144 MiB, and font coverage is what stops screenshots from rendering +as tofu boxes that a vision model cannot read. Both stay. + +Buzznode is the better home for this than Buzzbox: it is one persistent +computer for one agent, with its own volumes, browser profile, and login state. +That is the natural unit for a computer-use session. + +## What was trimmed + +KasmVNC supplies its own X server, so the packages Ubuntu ships for driving +real display hardware were never reachable: + +| Removed | Why | +| -------------------- | ------------------------------------------------------------------ | +| `xorg` metapackage | Pulls `xserver-xorg-core`, input/video drivers, `keyboard-configuration`, and `udev`/`systemd` for hardware the container does not have. | +| `x11-xserver-utils` | Its only consumer would be the `xrdb` call in KasmVNC's generated `xstartup`, and `xstartup` is replaced with `exec openbox-session`. It also drags in `cpp`/`gcc-13`. | + +Together: **64 packages, about 90 MiB**, taking `systemd`, `udev`, `man-db`, +and the apport chain out of an agent container as a side benefit. + +`xauth`, `xkb-data`, `x11-xkb-utils`, and `xfonts-base` are now listed +explicitly in the Dockerfile. The first three are `kasmvncserver` dependencies +and the fourth supplies the core font path Xvnc is started with; naming them +means no future autoremove can take them out. + +Verified after the change: Xvnc, Openbox, and tint2 start; 967 core fonts +resolve; `scrot`, `xdotool`, and `wmctrl` work; Chrome launches and maps a +window; KasmVNC serves on 6901; and `make check` and the `make smoke` +assertions pass. + +## What is deliberately kept + +| Kept | Size | Reason | +| -------------------------- | -------: | ---------------------------------------------------------------------- | +| Chrome | 414 MiB | Login flows today, computer-use target tomorrow. | +| Mesa and LLVM software GL | 186 MiB | Hard dependency of `kasmvncserver`, `picom`, and `libgbm1`. Removing it means dropping the compositor and GL entirely. | +| Fonts and icon themes | 144 MiB | Screenshot legibility for vision models; emoji and CJK coverage. | +| Ghostscript chain | 26 MiB | Structural: `openbox`'s `libobrender32v5` and `tint2` need imlib2, which needs `libspectre1`, which needs `libgs10`. Dropping `feh` alone frees only 10 MiB. | +| kitty | 19 MiB | The desktop's terminal, launched from Openbox autostart. | + +## Compression + +All figures are uncompressed on-disk size. Registry transfer is roughly 40–50% +of these, and Chrome compresses worse than the library and font bytes, so its +share of a `docker pull` is higher than its share here. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..007c785 --- /dev/null +++ b/Makefile @@ -0,0 +1,188 @@ +SHELL := /bin/bash +.DEFAULT_GOAL := help + +DOCKER ?= docker +IMAGE ?= pdparchitect/buzznode:local +CONTAINER ?= buzznode +PLATFORM ?= linux/amd64 +BUZZ_VERSION ?= 0.4.26 +BUZZ_DEB_SHA256 ?= 1b520756ecfc28ad81981a2cd5cc6688f785f447b3f5d8d553544906f59bf521 +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 +BIND_ADDRESS ?= 127.0.0.1 +PORT ?= 6904 +RELAY_URL ?= +BUZZ_NETWORK ?= +RESOLUTION ?= 1920x1080 +VNC_STATS ?= false +VOLUME_PREFIX ?= buzznode + +# Pass render nodes only. `--device=/dev/dri` would also hand over card*, the +# DRM master/modesetting node, which nothing in this container has a use for. +GPU_DEVICE := $(shell for node in /dev/dri/renderD*; do \ + [ -e "$$node" ] && echo "--device=$$node"; done) +RELAY_ENV := $(if $(strip $(RELAY_URL)),--env "BUZZ_RELAY_URL=$(RELAY_URL)",) +NETWORK_ARG := $(if $(strip $(BUZZ_NETWORK)),--network "$(BUZZ_NETWORK)",) + +.PHONY: help check build network run recreate up test smoke connection-test stop logs vnc-log status url size-report + +help: + @echo "Buzznode local Docker workflow" + @echo + @echo " make check Validate scripts, tests, and pinned metadata" + @echo " make build Build $(IMAGE)" + @echo " make run Start or create the Buzznode container" + @echo " make recreate Recreate the container without rebuilding" + @echo " make up Build and recreate the container" + @echo " make test Check, build, run, and smoke-test the environment" + @echo " make smoke Test the node desktop and headless agent tools" + @echo " make connection-test Test the configured external Buzz relay" + @echo " make logs Follow container logs" + @echo " make vnc-log Follow the KasmVNC session log" + @echo " make status Show container and node status" + @echo " make stop Stop and remove the container" + @echo " make url Print the local desktop URL" + @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 " 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 + bash tests/test-agent-runtime-login.sh + bash tests/test-buzznode.sh + @grep -q "^ARG BUZZ_VERSION=$(BUZZ_VERSION)$$" Dockerfile + @grep -q "^ARG BUZZ_DEB_SHA256=$(BUZZ_DEB_SHA256)$$" 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 + @echo "Buzznode metadata, setup CLI, and shell syntax are valid." + +build: + $(DOCKER) build \ + --platform "$(PLATFORM)" \ + --build-arg TARGETARCH=amd64 \ + --build-arg "BUZZ_VERSION=$(BUZZ_VERSION)" \ + --build-arg "BUZZ_DEB_SHA256=$(BUZZ_DEB_SHA256)" \ + --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)" \ + --tag "$(IMAGE)" \ + . + +network: + @if [ -n "$(strip $(BUZZ_NETWORK))" ]; then \ + if ! $(DOCKER) network inspect "$(BUZZ_NETWORK)" >/dev/null 2>&1; then \ + $(DOCKER) network create "$(BUZZ_NETWORK)" >/dev/null; \ + echo "Created Docker network $(BUZZ_NETWORK)."; \ + fi; \ + fi + +run: network + @if $(DOCKER) container inspect "$(CONTAINER)" >/dev/null 2>&1; then \ + if [ "$$($(DOCKER) container inspect --format '{{.State.Running}}' "$(CONTAINER)")" = "true" ]; then \ + echo "Container $(CONTAINER) is already running."; \ + else \ + $(DOCKER) start "$(CONTAINER)"; \ + fi; \ + else \ + $(DOCKER) run --detach \ + --name "$(CONTAINER)" \ + --platform "$(PLATFORM)" \ + --restart unless-stopped \ + --shm-size 1g \ + $(GPU_DEVICE) \ + $(NETWORK_ARG) \ + --publish "$(BIND_ADDRESS):$(PORT):6901" \ + $(RELAY_ENV) \ + --env "BUZZNODE_RESOLUTION=$(RESOLUTION)" \ + --env "BUZZNODE_VNC_STATS=$(VNC_STATS)" \ + --volume "$(VOLUME_PREFIX)-workspace:/workspace" \ + --volume "$(VOLUME_PREFIX)-config:/home/buzznode/.config" \ + --volume "$(VOLUME_PREFIX)-data:/home/buzznode/.local/share" \ + --volume "$(VOLUME_PREFIX)-nest:/home/buzznode/.buzz" \ + --volume "$(VOLUME_PREFIX)-codex:/home/buzznode/.codex" \ + --volume "$(VOLUME_PREFIX)-claude:/home/buzznode/.claude" \ + "$(IMAGE)"; \ + fi + @$(MAKE) --no-print-directory url + +recreate: + @$(MAKE) --no-print-directory stop + @$(MAKE) --no-print-directory run + +up: build recreate + +test: check up smoke + +smoke: + @echo "Waiting for Buzznode at http://$(BIND_ADDRESS):$(PORT) ..." + @ready=false; \ + for attempt in $$(seq 1 60); do \ + if curl --fail --silent "http://$(BIND_ADDRESS):$(PORT)/index.html" >/dev/null; then \ + ready=true; \ + break; \ + fi; \ + sleep 2; \ + done; \ + if [ "$$ready" != "true" ]; then \ + echo "Buzznode did not become ready within 120 seconds."; \ + $(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' + @echo "Buzznode is ready with a desktop and one headless agent harness." + +connection-test: + @$(DOCKER) exec \ + --user agent \ + "$(CONTAINER)" \ + buzznode doctor + +stop: + @if $(DOCKER) container inspect "$(CONTAINER)" >/dev/null 2>&1; then \ + $(DOCKER) rm --force "$(CONTAINER)"; \ + else \ + echo "Container $(CONTAINER) does not exist."; \ + fi + +logs: + $(DOCKER) logs --follow "$(CONTAINER)" + +vnc-log: + $(DOCKER) exec "$(CONTAINER)" \ + bash -c 'tail --lines=200 --follow /home/buzznode/.vnc/*:1.log' + +status: + @$(DOCKER) ps --all \ + --filter "name=^/$(CONTAINER)$$" \ + --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' + @if $(DOCKER) container inspect "$(CONTAINER)" >/dev/null 2>&1; then \ + $(DOCKER) exec "$(CONTAINER)" buzznode status || true; \ + fi + +url: + @echo "Desktop: http://$(BIND_ADDRESS):$(PORT)" + +size-report: + @DOCKER="$(DOCKER)" bash tools/size-report.sh "$(IMAGE)" diff --git a/README.md b/README.md index 219c8d1..a42ecf3 100644 --- a/README.md +++ b/README.md @@ -1 +1,325 @@ -# buzznode \ No newline at end of file +# Buzznode + +Buzznode is one persistent, browser-accessible Linux computer for one +[Buzz](https://github.com/block/buzz) agent. It joins an existing Buzz +workspace and runs that agent through the headless `buzz-acp` harness. + +Buzznode is not a Buzz workspace client. It does not contain Buzz Desktop, and +it does not run a relay, database, object store, or other server-side Buzz +service. Use Buzzbox or another Buzz client to manage the workspace, create +agents, and communicate with them. Use Buzznode to give one of those agents a +dedicated browser, terminal, filesystem, runtime login, and long-lived state. + +> **Not an official Buzz project.** Buzznode is an independent, community-built +> environment that packages published Buzz releases. It is not affiliated with, +> endorsed by, or sponsored by the Buzz project, [buzz.xyz](https://buzz.xyz), +> or Block, Inc. "Buzz" is used here only to describe what this image runs and +> what it is compatible with; all trademarks belong to their respective owners. +> Report problems with Buzznode here, not to the upstream Buzz project. + +## Deployment model + +Buzznode has no runtime dependency on Buzzbox. It can connect to any compatible +Buzz relay that is reachable from the container, including a hosted relay over +`wss://`, a relay on another server, or a local development relay. + +Buzzbox is only an optional onboarding convenience. Its Agent Setup menu can +create a managed agent and package the relay URL, identity, authorization, and +response policy into a `buzznode-v1:` enrollment bundle. Another Buzz client or +provisioning system can supply the same information instead. + +The `buzz-local` Docker network, `ws://buzzbox:3000` relay address, and the two +projects' coordinated Makefile examples are strictly for local testing. They +are not part of the Buzznode architecture and are not required in deployment. + +## How the pieces fit + +```text +Any compatible Buzz workspace Independent Buzznode +------------------------------------- --------------------------- +Create and manage workspace Run one existing agent +Provide enrollment or credentials ---> Connect to its configured relay +Add agent to channels Run Codex, Claude, or Goose +Chat with and mention agent <---- Handle messages through buzz-acp +``` + +The `buzz` command-line tool remains in the node because `buzz-acp` makes it +available to the running agent for Buzz messaging and tools. The graphical +`buzz-desktop` application is deliberately absent and cannot be started. + +## Quick start + +Buzznode currently targets `linux/amd64`, matching the upstream Buzz binaries. + +First, create or select a managed agent in the Buzz client associated with your +relay. You need either: + +- a `buzznode-v1:` enrollment bundle; or +- the relay URL, agent private key, authorization, and response policy for + manual setup. + +When using Buzzbox, its convenience flow is: + +1. Right-click the Buzzbox desktop and choose **Agent Setup → Create New Agent + for Buzznode**. +2. Complete Buzz's managed-agent form and save it. +3. Copy the `buzznode-v1:` enrollment bundle displayed by Buzzbox. + +To use an existing agent instead, stop its local harness and choose **Agent +Setup → Move Existing Agent to Buzznode**. + +When using another Buzz workspace, obtain the managed agent credentials through +that workspace's client or provisioning process. Buzznode does not contact, +discover, or require a Buzzbox instance. + +Then start Buzznode: + +```bash +docker pull ghcr.io/pdparchitect/buzznode:latest +docker run --detach \ + --name buzznode \ + --platform linux/amd64 \ + --restart unless-stopped \ + --shm-size 1g \ + --publish 127.0.0.1:6904:6901 \ + --volume buzznode-workspace:/workspace \ + --volume buzznode-config:/home/buzznode/.config \ + --volume buzznode-data:/home/buzznode/.local/share \ + --volume buzznode-nest:/home/buzznode/.buzz \ + --volume buzznode-codex:/home/buzznode/.codex \ + --volume buzznode-claude:/home/buzznode/.claude \ + ghcr.io/pdparchitect/buzznode:latest +``` + +Open . The first desktop opens a larger terminal setup +window. Paste the enrollment bundle, then choose a Codex, Claude Code, or Goose +runtime. The wizard confirms completion and waits for Enter before becoming a +normal Buzznode terminal; it does not close the window. The bundle supplies: + +- the workspace relay WebSocket URL, such as `wss://buzz.example.com`; +- the agent private key; +- its authorization tag; and +- whether anyone, its owner, an allowlist, or nobody may activate it. + +Buzznode separately asks for an optional relay API token because Buzzbox does +not store that token with the managed agent. + +Buzznode validates the bundle's relay, private key, authorization, and response +policy before reporting that enrollment was accepted. + +The enrollment bundle is base64-encoded, not encrypted, and contains the agent +private key. Treat it like a password and paste it only into Buzznode setup. +Once the node is connected, do not run the same agent's local harness in +Buzzbox. + +The wizard stores connection credentials in +`~/.config/buzznode/environment` with mode `0600`, offers to configure the +selected runtime, and starts `buzz-acp`. Buzznode discovers the channels that +contain this agent and handles messages addressed to it. + +For unattended provisioning, configure the node from its terminal: + +```bash +printf '%s\n' "$BUZZNODE_ENROLLMENT_BUNDLE" | + buzznode configure --enrollment-stdin --runtime codex +buzznode runtime-login +buzznode start +``` + +Manual `--relay-url`, `--private-key`, `--auth-tag`, `--respond-to`, and +`--respond-to-allowlist` options remain available for non-Buzzbox clients. Use +`buzznode setup` for normal interactive configuration so secrets do not appear +in shell history. A raw key from `buzz-admin generate-key` is not a replacement +for creating a managed agent because it has no Buzz profile, owner attestation, +or channel membership. + +## Test locally with Buzzbox + +Buzzbox and Buzznode remain independent projects. Their Makefiles coordinate +only through an optional Docker network and the relay URL. + +From the `buzzbox` directory: + +```bash +make up \ + BUZZ_NETWORK=buzz-local \ + PUBLIC_RELAY_URL=ws://buzzbox:3000 +``` + +Open Buzzbox at and choose **Agent Setup → Create New +Agent for Buzznode**. Complete the Buzz form and copy the enrollment bundle. + +From this `buzznode` directory: + +```bash +make up \ + BUZZ_NETWORK=buzz-local \ + RELAY_URL=ws://buzzbox:3000 +``` + +Open Buzznode at . The relay URL is prefilled in the +setup wizard for manual setup, but the recommended flow is to paste the +Buzzbox enrollment bundle. Leave the local API token empty, select a runtime, +and complete its login. + +Once setup is complete, verify the saved configuration and relay connection: + +```bash +make connection-test +``` + +The first project started creates `buzz-local`; Docker DNS resolves `buzzbox` +on that network. Each Makefile still owns only its own container. `make stop` +in either directory does not stop or remove the other project. + +## One node, one agent + +Create another Buzznode container with a different container name and volume +prefix for another agent. Keeping nodes isolated gives each agent its own: + +- Buzz identity and harness process; +- browser sessions and runtime credentials; +- desktop and filesystem state; +- `/workspace`; and +- start, stop, restart, and logs lifecycle. + +## What is included + +- the headless `buzz-acp`, `buzz`, `buzz-agent`, and `buzz-dev-mcp` tools; +- Codex with `codex-acp`; +- Claude Code with `claude-agent-acp`; +- Goose with native ACP support; +- Chrome 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. + +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 +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 +to reach the node: a terminal, a log view, and a file browser served over HTTP +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. + +## Node commands + +```bash +buzznode setup +buzznode status +buzznode doctor +buzznode runtime-login +buzznode runtime-login codex device +buzznode start +buzznode stop +buzznode restart +buzznode logs +``` + +Right-click the desktop for the same agent controls, runtime login actions, +terminal, browser, task manager, and harness logs. Runtime login opens a +chooser instead of assuming browser authentication. Codex supports device code, +desktop browser, or API-key authentication. Claude Code supports Claude +subscription, Anthropic Console, long-lived setup-token, or organization SSO +flows. + +## Build locally + +From this directory: + +```bash +make check +make up +make smoke +``` + +The desktop opens at . Useful overrides include: + +```bash +PORT=8080 RESOLUTION=1600x900 make up +DOCKER=podman make up +``` + +`make stop` removes the node container but preserves its named volumes. + +## Pinned components + +| Component | Version | +| ------------------- | --------- | +| Buzz headless tools | `0.4.26` | +| Codex | `0.145.0` | +| Claude Code | `2.1.220` | +| Goose | `1.44.0` | +| 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. + +## Persistence + +Keep separate volumes for: + +- `/workspace` — the node's working files; +- `~/.config` and `~/.local/share` — node, browser, desktop, and Goose state; +- `~/.buzz` — the Buzz agent nest; +- `~/.codex` — Codex state; and +- `~/.claude` — Claude Code state. + +## Security + +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. + +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 +publish port `6901` directly to an untrusted network, expose the saved agent +key, or reuse a human Buzz private key as the node identity. + +See [RELEASES.md](RELEASES.md) for the image release process. + +### Authentication is delegated by design + +Access to the desktop web application is not authenticated. KasmVNC is started +with `-disableBasicAuth` and TLS disabled, and the KasmVNC password written on +first boot exists only because KasmVNC checks that the file is there. It is not +an access control and should not be treated as one. + +This is a deliberate decision, and built-in authentication is not planned. A +node reachable beyond loopback is expected to be fronted by whichever access +layer already suits its environment: a conventional reverse proxy, or +preferably a zero-trust access proxy such as Cloudflare Access or an equivalent +identity-aware proxy. Those systems already own identity, session lifetime, +device posture, revocation, and audit, and in an enterprise deployment they are +the layer that has to be satisfied regardless of what the node does. + +Adding a second mechanism inside the node would not strengthen that +arrangement, it would compete with it: two session models to keep aligned, two +places to revoke an operator, and an open question about which one wins when +they disagree. Leaving the node unauthenticated keeps a single enforcement +point and a single path forward — the proxy is the front door, and there is no +second door to reason about or accidentally leave open. + +The practical consequence is that the loopback bind is the only boundary until +an access layer is put in front of it. Treat exposing the node without one as +publishing an unauthenticated root shell, because that is what it is. diff --git a/RELEASES.md b/RELEASES.md new file mode 100644 index 0000000..0b4af53 --- /dev/null +++ b/RELEASES.md @@ -0,0 +1,29 @@ +# Releasing Buzznode + +Buzznode releases are driven by the `VERSION` file. + +## Release process + +1. Update `VERSION` with a semantic version without a `v` prefix. +2. Move the relevant entries from `Unreleased` into a matching version section + in `CHANGELOG.md`. +3. Merge the release change into `main`. +4. CI validates, builds, and smoke-tests the Buzznode image. +5. After CI succeeds, the tag workflow creates an annotated `v*` tag at the + exact tested commit. +6. The release workflow publishes + `ghcr.io/pdparchitect/buzznode`, immutable version tags, an SBOM and + provenance, and a matching GitHub Release. + +Existing tags and releases are never replaced. + +## Image tags + +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. + +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 new file mode 100644 index 0000000..6e8bf73 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.1.0 diff --git a/browser/index.html b/browser/index.html new file mode 100644 index 0000000..33d8e5f --- /dev/null +++ b/browser/index.html @@ -0,0 +1,59 @@ + + + + + + Buzznode + + + +
+

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/cortile/cortile-config.toml b/cortile/cortile-config.toml new file mode 100644 index 0000000..5bf9dee --- /dev/null +++ b/cortile/cortile-config.toml @@ -0,0 +1,139 @@ +################################################################################ +# Cortile config for Pantalk desktop example # +# https://github.com/leukipp/cortile/blob/main/config.toml # +################################################################################ + +#################################### Tiling #################################### + +# Start in floating mode; user enables tiling via right-click menu or keyboard. +tiling_enabled = false + +# Default tiling layout when enabled. +tiling_layout = "vertical-right" + +# Layouts available when cycling with next/previous. +# @note "maximized" is intentionally excluded - it causes every window +# to appear maximized and prevents genuine tiling after manual maximize +tiling_cycle = [ + "vertical-left", + "vertical-right", + "horizontal-top", + "horizontal-bottom", +] + +# Show overlay briefly when layout changes (ms, 0 = disabled). +tiling_gui = 1200 + +# Systray menu entries (action name from [keys], display label). +tiling_icon = [ + ["toggle", "Toggle Tiling"], + ["", ""], + ["cycle_next", "Next Layout"], + ["cycle_previous", "Previous Layout"], + ["", ""], + ["reset", "Reset"], + ["", ""], + ["exit", "Exit"], +] + +#################################### Window #################################### + +# Regex to ignore windows from tiling (WM_CLASS, WM_NAME). +window_ignore = [ + ["tint2.*", ""], + ["nm.*", ""], + ["gcr.*", ""], + ["polkit.*", ""], + ["wrapper.*", ""], +] + +window_masters_max = 1 +window_slaves_max = 3 +window_gap_size = 8 +window_focus_delay = 0 +window_decoration = true + +################################## Proportion ################################## + +proportion_step = 0.05 +proportion_min = 0.2 + +##################################### Edge ##################################### + +# Openbox already reserves 8px on all screen edges; +# Cortile sees the reduced workspace, so no extra edge margin needed. +edge_margin = [0, 0, 0, 0] +edge_margin_primary = [0, 0, 0, 0] +edge_corner_size = 10 +edge_center_size = 100 + +################################################################################ +[colors] +################################################################################ + +gui_text = [255, 255, 255, 255] +gui_background = [30, 30, 40, 230] +gui_client_slave = [58, 58, 78, 255] +gui_client_master = [98, 98, 128, 255] +icon_background = [0, 0, 0, 0] +icon_foreground = [255, 255, 255, 255] + +################################################################################ +[keys] +################################################################################ + +enable = "Control-Shift-Home" +disable = "Control-Shift-End" +toggle = "Control-Shift-T" +decoration = "Control-Shift-D" +restore = "Control-Shift-R" +reset = "Control-Shift-BackSpace" +cycle_next = "Control-Shift-Next" +cycle_previous = "Control-Shift-Prior" +layout_vertical_left = "Control-Shift-Left" +layout_vertical_right = "Control-Shift-Right" +layout_horizontal_top = "Control-Shift-Up" +layout_horizontal_bottom = "Control-Shift-Down" +# @note maximized layout is intentionally unbound to prevent accidental activation +layout_maximized = "" +layout_fullscreen = "Control-Shift-Return" +slave_increase = "Control-Shift-plus" +slave_decrease = "Control-Shift-minus" +master_increase = "Control-Shift-KP_Add" +master_decrease = "Control-Shift-KP_Subtract" +window_next = "Control-Shift-KP_2" +window_previous = "Control-Shift-KP_8" +screen_next = "Control-Shift-KP_9" +screen_previous = "Control-Shift-KP_7" +master_make = "Control-Shift-KP_5" +master_make_next = "Control-Shift-KP_6" +master_make_previous = "Control-Shift-KP_4" +proportion_increase = "Control-Shift-KP_3" +proportion_decrease = "Control-Shift-KP_1" +mod_screens = "Mod1" +mod_workspaces = "Mod4" + +################################################################################ +[corners] +################################################################################ + +top_left = "" +top_center = "" +top_right = "" +center_right = "" +bottom_right = "" +bottom_center = "" +bottom_left = "" +center_left = "" + +################################################################################ +[systray] +################################################################################ + +click_left = "" +click_middle = "toggle" +click_right = "" +scroll_up = "cycle_previous" +scroll_down = "cycle_next" +scroll_left = "proportion_decrease" +scroll_right = "proportion_increase" diff --git a/cortile/cortilectl b/cortile/cortilectl new file mode 100644 index 0000000..338c6bf --- /dev/null +++ b/cortile/cortilectl @@ -0,0 +1,122 @@ +#!/bin/bash +# cortilectl - thin wrapper to manage cortile from Openbox menus. +# Usage: cortilectl {start|stop|on|off|toggle|next-layout|prev-layout} + +set -euo pipefail + +# Ensure DISPLAY is set (Openbox menu inherits it, but autostart may not). +export DISPLAY="${DISPLAY:-:1}" + +log_file="/tmp/cortile.log" + +is_running() { + pgrep -u "$(id -u)" -x cortile >/dev/null 2>&1 +} + +start_cortile() { + if ! is_running; then + nohup cortile -v >>"$log_file" 2>&1 & + disown + # Wait for cortile to connect to X and start listening. + local i=0 + while [ $i -lt 20 ]; do + if is_running; then + break + fi + sleep 0.25 + i=$((i + 1)) + done + + # Give Cortile a brief moment to finish grabbing keybindings after process start. + if is_running; then + sleep 0.75 + fi + fi +} + +unmaximize_all() { + # @note strip _NET_WM_STATE maximized hints from every window so Cortile + # and Openbox both see them as normal-sized; without this, maximized + # windows resist any tiling resize + local wids + if command -v wmctrl >/dev/null 2>&1; then + wmctrl -l | awk '{print $1}' | while read -r wid; do + wmctrl -i -r "$wid" -b remove,maximized_vert,maximized_horz 2>/dev/null || true + done + else + # Fallback using xprop (always available in X11 installs). + wids=$(xprop -root _NET_CLIENT_LIST 2>/dev/null \ + | grep -o '0x[0-9a-f]\+' || true) + for wid in $wids; do + # Read current state, strip maximized atoms, rewrite. + local cur + cur=$(xprop -id "$wid" _NET_WM_STATE 2>/dev/null \ + | sed 's/.*= //' | tr ',' '\n' \ + | grep -iv 'maximized' | tr '\n' ',' \ + | sed 's/,$//' || true) + if [ -n "$cur" ]; then + xprop -id "$wid" -f _NET_WM_STATE 32a -set _NET_WM_STATE "$cur" 2>/dev/null || true + else + xprop -id "$wid" -remove _NET_WM_STATE 2>/dev/null || true + fi + done + fi +} + +reset_cortile_layout() { + # @note send Cortile's reset key to clear any stuck internal layout state + # (e.g. "maximized" mode), then force the default vertical-right layout + sleep 0.1 + xdotool key --clearmodifiers "ctrl+shift+BackSpace" 2>>"$log_file" || true + sleep 0.1 + xdotool key --clearmodifiers "ctrl+shift+Right" 2>>"$log_file" || true +} + +send_key() { + # @note openbox holds a keyboard grab while closing the menu that triggered + # this command; a short pause lets it release the grab so the synthetic + # keypress reaches cortile's global key listener on the first attempt + sleep 0.3 + # Use xdotool to simulate the Cortile keybinding. + xdotool key --clearmodifiers "$1" 2>>"$log_file" || true +} + +mode="${1:-toggle}" + +case "$mode" in + start) + start_cortile + ;; + stop) + pkill -u "$(id -u)" -x cortile 2>/dev/null || true + ;; + on|enable) + start_cortile + unmaximize_all + send_key "ctrl+shift+Home" + reset_cortile_layout + ;; + off|disable) + if is_running; then + send_key "ctrl+shift+End" + fi + ;; + toggle) + start_cortile + unmaximize_all + send_key "ctrl+shift+t" + reset_cortile_layout + ;; + next-layout) + start_cortile + send_key "ctrl+shift+Next" + ;; + prev-layout) + start_cortile + send_key "ctrl+shift+Prior" + ;; + *) + echo "Usage: cortilectl {start|stop|on|off|toggle|next-layout|prev-layout}" >&2 + exit 2 + ;; +esac diff --git a/init.sh b/init.sh new file mode 100755 index 0000000..f77c9aa --- /dev/null +++ b/init.sh @@ -0,0 +1,210 @@ +#!/bin/bash +# Buzznode container entrypoint. +# Starts one persistent browser-accessible desktop that connects to an external +# Buzz relay. Buzznode deliberately runs no relay or backing data services. + +set -euo pipefail + +export HOME=/home/buzznode +export XDG_CONFIG_HOME="$HOME/.config" +export XDG_DATA_HOME="$HOME/.local/share" + +agent_uid="$(id -u agent)" +export XDG_RUNTIME_DIR="/run/user/${agent_uid}" + +resolution="${BUZZNODE_RESOLUTION:-1920x1080}" +if [[ ! "$resolution" =~ ^[0-9]{3,5}x[0-9]{3,5}$ ]]; then + echo "[buzznode] invalid BUZZNODE_RESOLUTION: $resolution" >&2 + exit 1 +fi + +width="${resolution%x*}" +height="${resolution#*x}" + +mkdir -p \ + "$HOME/.vnc" \ + "$HOME/.config/buzznode" \ + "$HOME/.local/share/applications" \ + "$HOME/.buzz" \ + "$HOME/.codex" \ + "$HOME/.claude" \ + "$XDG_RUNTIME_DIR" \ + /workspace \ + /var/log/buzznode \ + /tmp/.X11-unix + +# Keep the durable workspace and the agent's home available as Ranger +# bookmarks without replacing any bookmarks the user has already assigned. +ranger_data_dir="$XDG_DATA_HOME/ranger" +ranger_bookmarks="$ranger_data_dir/bookmarks" +mkdir -p "$ranger_data_dir" +touch "$ranger_bookmarks" +if ! grep -q '^W:' "$ranger_bookmarks"; then + printf 'W:/workspace\n' >> "$ranger_bookmarks" +fi +if ! grep -q '^H:' "$ranger_bookmarks"; then + printf 'H:%s\n' "$HOME" >> "$ranger_bookmarks" +fi + +# Ownership only needs normalizing once per volume lifetime. Recursing the home +# directory and the workspace on every boot walks the browser profile, the agent +# nest, and every checked-out repository, which becomes minutes of startup +# latency once they hold real data. Anything created later is created by the +# agent user already. +persistent_paths=( + "$HOME" + /workspace +) +ownership_stamp="$HOME/.config/buzznode/.ownership-normalized" + +chown agent:agent \ + "${persistent_paths[@]}" \ + "$XDG_RUNTIME_DIR" \ + /var/log/buzznode + +if [ ! -e "$ownership_stamp" ]; then + chown -R agent:agent "${persistent_paths[@]}" /var/log/buzznode + touch "$ownership_stamp" + chown agent:agent "$ownership_stamp" + echo "[buzznode] normalized ownership of the persistent volumes" +fi + +chmod 700 "$XDG_RUNTIME_DIR" "$HOME/.config/buzznode" +chmod 1777 /tmp/.X11-unix + +if getent group ssl-cert >/dev/null 2>&1; then + usermod -a -G ssl-cert agent +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 +# presence alone does not mean usable. Announcing hw3d in that case leaves Xvnc +# and Chrome retrying against a device they cannot open. +gpu_node="" +gpu_node_blocked="" +for node in /dev/dri/renderD*; do + [ -e "$node" ] || continue + printf -v node_q '%q' "$node" + if su -s /bin/bash -c "test -r $node_q && test -w $node_q" agent; then + gpu_node="$node" + break + fi + gpu_node_blocked="$node" +done + +if [ -n "$gpu_node" ]; then + gpu_config=" gpu: + hw3d: true + drinode: $gpu_node" + echo "[buzznode] GPU acceleration enabled via $gpu_node" +else + gpu_config=" gpu: + hw3d: false" + if [ -n "$gpu_node_blocked" ]; then + echo "[buzznode] $gpu_node_blocked is not readable by the agent user;" \ + "using software rendering" + else + echo "[buzznode] no GPU render node found; using software rendering" + fi +fi + +cat > "$HOME/.vnc/kasmvnc.yaml" <> "$HOME/.vnc/kasmvnc.yaml" <<'YAML' + +logging: + log_writer_name: EncodeManager + log_dest: logfile + level: 100 +YAML + echo "[buzznode] KasmVNC encoder statistics enabled" +fi + +cat > "$HOME/.vnc/xstartup" <<'XSTARTUP' +#!/bin/bash +exec openbox-session +XSTARTUP +chmod +x "$HOME/.vnc/xstartup" +touch "$HOME/.vnc/.de-was-selected" +chown -R agent:agent "$HOME/.vnc" + +# KasmVNC checks these even while browser authentication and TLS are disabled. +su -s /bin/bash -c ' + openssl req -x509 -nodes -days 3650 -newkey rsa:2048 \ + -keyout "$HOME/.vnc/self.pem" \ + -out "$HOME/.vnc/self.pem" \ + -subj "/CN=buzznode" >/dev/null 2>&1 + printf "buzznode\nbuzznode\n" | kasmvncpasswd -u agent -wo >/dev/null 2>&1 || true +' agent + +# shellcheck disable=SC2329 +cleanup() { + echo "[buzznode] stopping" + su -s /bin/bash -c 'kasmvncserver -kill :1 >/dev/null 2>&1 || true' agent + pkill -TERM -u agent -f '(^|/)buzz-acp($| )' 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +su -s /bin/bash -c 'kasmvncserver -kill :1 >/dev/null 2>&1 || true' agent +rm -f /tmp/.X1-lock /tmp/.X11-unix/X1 + +su -s /bin/bash -c " + export HOME='$HOME' + export DISPLAY=:1 + export XDG_CONFIG_HOME='$XDG_CONFIG_HOME' + export XDG_DATA_HOME='$XDG_DATA_HOME' + export XDG_RUNTIME_DIR='$XDG_RUNTIME_DIR' + exec kasmvncserver :1 \ + -disableBasicAuth \ + -interface 0.0.0.0 \ + -websocketPort 6901 \ + -publicIP 127.0.0.1 \ + -geometry '$resolution' \ + -depth 24 \ + -httpd /usr/share/kasmvnc/www \ + -BlacklistThreshold 0 \ + -FreeKeyMappings +" agent >>/var/log/buzznode/kasmvnc.log 2>&1 & + +for attempt in $(seq 1 40); do + if curl -fsS http://127.0.0.1:6901/ >/dev/null 2>&1; then + echo "[buzznode] desktop ready at http://localhost:6901" + break + fi + if [ "$attempt" -eq 40 ]; then + echo "[buzznode] KasmVNC did not become ready" >&2 + tail -n 100 /var/log/buzznode/kasmvnc.log >&2 || true + exit 1 + fi + sleep 1 +done + +while curl -fsS http://127.0.0.1:6901/ >/dev/null 2>&1; do + sleep 5 +done + +echo "[buzznode] browser environment stopped unexpectedly" >&2 +exit 1 diff --git a/kasm/custom.css b/kasm/custom.css new file mode 100644 index 0000000..3e98ac4 --- /dev/null +++ b/kasm/custom.css @@ -0,0 +1,47 @@ +/* KasmVNC UI overrides - hide branding, sidebar, black background. */ +.noVNC_logo { + display: none !important; +} +body, +#noVNC_container, +.noVNC_container { + background-color: #000 !important; + background-image: none !important; +} +#noVNC_control_bar { + display: none !important; +} +#noVNC_control_bar_hint { + display: none !important; +} + +/* Hide loading / transition / status screens. */ +#noVNC_transition { + display: none !important; + background: #000 !important; + background-image: none !important; +} +#noVNC_transition_text { + display: none !important; +} +.noVNC_spinner, +.noVNC_spinner::before, +.noVNC_spinner::after { + display: none !important; +} +#noVNC_status { + display: none !important; +} +#noVNC_connect_dlg { + display: none !important; +} +.noVNC_connect_layer { + display: none !important; +} + +/* Hide version / fallback error branding. */ +.noVNC_version_wrapper, +.noVNC_version, +.noVNC_version_separator { + display: none !important; +} diff --git a/kasm/favicon.svg b/kasm/favicon.svg new file mode 100644 index 0000000..95e4174 --- /dev/null +++ b/kasm/favicon.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + diff --git a/kasm/patch.sh b/kasm/patch.sh new file mode 100644 index 0000000..20d5e29 --- /dev/null +++ b/kasm/patch.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Patch KasmVNC web assets to remove branding and apply customisations. +# Run once after installing the kasmvnc .deb package. +set -euo pipefail + +WWW=/usr/share/kasmvnc/www + +# 1. Inject custom assets, rebrand the title, and replace upstream icon links. +find "$WWW" -maxdepth 1 -name '*.html' -exec sed -i \ + -e 's|[^<]*|Buzznode|' \ + -e 's|]*rel="icon"[^>]*>||g' \ + -e 's|]*rel="apple-touch-icon"[^>]*>||g' \ + -e 's|||' \ + {} + + +# 2. Replace the "KasmVNC" brand string and keep the browser title fixed. +# KasmVNC otherwise replaces it after connecting with the VNC desktop name, +# which contains Docker's generated hostname. +find "$WWW/assets" -name 'ui-*.js' -exec sed -i \ + -e 's|"KasmVNC"|"Buzznode"|g' \ + -e 's|document.title=r.detail.name+" - "+ox|document.title=ox|g' \ + {} + + +if grep -ERq 'document\.title=[[:alnum:]_$]+\.detail\.name\+" - "\+' \ + "$WWW/assets"/ui-*.js; then + echo "[kasm-patch] dynamic VNC desktop title was not removed" >&2 + exit 1 +fi + +echo "[kasm-patch] KasmVNC UI patched successfully" diff --git a/openbox/autostart b/openbox/autostart new file mode 100755 index 0000000..a61cb20 --- /dev/null +++ b/openbox/autostart @@ -0,0 +1,82 @@ +#!/bin/bash +# Openbox autostart - runs when the browser desktop session begins. + +set -e + +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 +Exec=chromium %u +Type=Application +MimeType=x-scheme-handler/http;x-scheme-handler/https;text/html; +NoDisplay=true +CHROMEDESKTOP + cat > "$HOME/.config/mimeapps.list" <<'MIMEAPPS' +[Default Applications] +x-scheme-handler/http=chromium-buzznode.desktop +x-scheme-handler/https=chromium-buzznode.desktop +text/html=chromium-buzznode.desktop +MIMEAPPS + xdg-settings set default-web-browser chromium-buzznode.desktop 2>/dev/null || true +fi +export BROWSER=chromium + +# Kitty is tuned for a software-rendered remote display. +mkdir -p "$HOME/.config/kitty" +cat > "$HOME/.config/kitty/kitty.conf" <<'KITTYCONF' +detect_urls yes +open_url_with chromium +url_color #5599ff +cursor_shape block +shell_integration no-cursor +confirm_os_window_close 0 +map ctrl+c copy_or_interrupt +map ctrl+v paste_from_clipboard +window_padding_width 8 +repaint_delay 40 +input_delay 8 +sync_to_monitor no +cursor_blink_interval 0 +mouse_hide_wait 0 +disable_ligatures always +resize_debounce_time 0.1 +KITTYCONF + +wallpaper="/usr/share/backgrounds/buzz-grid.svg" +rm -f "$HOME/.fehbg" "$HOME/.wallpaper" +( + last_res="" + while true; do + cur_res="$(xdpyinfo -display "${DISPLAY:-:1}" 2>/dev/null \ + | awk '/dimensions/{print $2}')" + if [ -n "$cur_res" ] && [ "$cur_res" != "$last_res" ]; then + feh --no-fehbg --bg-tile "$wallpaper" + last_res="$cur_res" + fi + sleep 2 + done +) & + +# The image owns the panel layout. Load it explicitly so Tint2 does not prefer +# a stale tint2rc copied into the persistent user config on an earlier run. +tint2 -c /etc/xdg/tint2/tint2rc & +cortilectl start & + +# A configured node starts its headless agent harness and opens the node +# terminal. A new node opens the terminal-first setup wizard. +if buzznode configured; then + buzznode start + kitty --title "Buzznode" \ + --override remember_window_size=no \ + --override initial_window_width=100c \ + --override initial_window_height=30c \ + -e welcome & +else + kitty --title "Set up Buzznode" \ + --override remember_window_size=no \ + --override initial_window_width=100c \ + --override initial_window_height=44c \ + -e bash -lc 'buzznode setup; exec bash' & +fi diff --git a/openbox/menu.xml b/openbox/menu.xml new file mode 100644 index 0000000..d2a9770 --- /dev/null +++ b/openbox/menu.xml @@ -0,0 +1,140 @@ + + + + + + kitty --title "Buzznode" --override remember_window_size=no --override initial_window_width=100c --override initial_window_height=30c -e welcome + + + + + + kitty + + + + + kitty -e ranger /workspace + + + + + kitty -e htop + + + + + + chromium file:///opt/browser/index.html + + + + + + + kitty --title "Buzznode Setup" --override remember_window_size=no --override initial_window_width=100c --override initial_window_height=44c -e bash -lc "buzznode setup; exec bash" + + + + + kitty --title "Buzznode Status" -e bash -lc "buzznode status; exec bash" + + + + + kitty --title "Buzznode Doctor" -e bash -lc "buzznode doctor; exec bash" + + + + + + kitty --title "Start Agent" -e bash -lc "buzznode start; sleep 2; buzznode status; exec bash" + + + + + kitty --title "Stop Agent" -e bash -lc "buzznode stop; sleep 2; buzznode status; exec bash" + + + + + kitty --title "Restart Agent" -e bash -lc "buzznode restart; sleep 2; buzznode status; exec bash" + + + + + + + kitty --title "Codex Login" -e bash -lc "CODEX_LOGIN_DEFAULT=device agent-runtime-login codex; exec bash" + + + + + kitty --title "Claude Login" -e bash -lc "agent-runtime-login claude; exec bash" + + + + + kitty --title "Goose Setup" -e bash -lc "agent-runtime-login goose; exec bash" + + + + + + kitty --title "Codex Status" -e bash -lc "codex login status; exec bash" + + + + + kitty --title "Claude Status" -e bash -lc "claude auth status; exec bash" + + + + + kitty --title "Goose Info" -e bash -lc "goose info; exec bash" + + + + + + + kitty --title "Agent Harness Log" -e buzznode logs + + + + + kitty -e ranger /var/log/buzznode + + + + + + + + cortilectl on + + + + + cortilectl off + + + + + cortilectl toggle + + + + + + cortilectl next-layout + + + + + cortilectl prev-layout + + + + + diff --git a/openbox/rc.xml b/openbox/rc.xml new file mode 100644 index 0000000..1eca311 --- /dev/null +++ b/openbox/rc.xml @@ -0,0 +1,216 @@ + + + + 10 + 20 + + + yes + no + yes + no + 200 + no + + + Smart +
yes
+
+ + 8 + 8 + 52 + 8 + + + Never + + + Triste-Crimson + LIMC + yes + no + + Noto Sans + 9 + Bold + Normal + + + Noto Sans + 9 + Normal + Normal + + + Noto Sans + 9 + Bold + Normal + + + Noto Sans + 9 + Normal + Normal + + + Noto Sans + 9 + Bold + Normal + + + Noto Sans + 9 + Normal + Normal + + + + 1 + 1 + + Desktop + + + + + + + + + + + + + + + + + + + + + + + + + + + kitty + + + + + kitty + + + + + + + + root-menu + + + + + root-menu + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + root-menu + + + + + + + + + + + + + + + + + + + + + + + no + + +
diff --git a/openbox/theme/bullet.xbm b/openbox/theme/bullet.xbm new file mode 100644 index 0000000..976273b --- /dev/null +++ b/openbox/theme/bullet.xbm @@ -0,0 +1,5 @@ +#define bullet_width 10 +#define bullet_height 10 +static unsigned char bullet_bits[] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x30, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; diff --git a/openbox/theme/close.xbm b/openbox/theme/close.xbm new file mode 100644 index 0000000..3e327e3 --- /dev/null +++ b/openbox/theme/close.xbm @@ -0,0 +1,4 @@ +#define desk_width 6 +#define desk_height 6 +static unsigned char desk_bits[] = { + 0x33, 0x33, 0x00, 0x00, 0x33, 0x33 }; diff --git a/openbox/theme/desk.xbm b/openbox/theme/desk.xbm new file mode 100644 index 0000000..9598c82 --- /dev/null +++ b/openbox/theme/desk.xbm @@ -0,0 +1,4 @@ +#define desk_width 6 +#define desk_height 6 +static unsigned char desk_bits[] = { + 0x3f, 0x3f, 0x3f, 0x3f, 0x3f, 0x3f }; diff --git a/openbox/theme/desk_toggled.xbm b/openbox/theme/desk_toggled.xbm new file mode 100644 index 0000000..8daa5de --- /dev/null +++ b/openbox/theme/desk_toggled.xbm @@ -0,0 +1,4 @@ +#define desk_width 6 +#define desk_height 6 +static unsigned char desk_bits[] = { + 0x3f, 0x3f, 0x33, 0x33, 0x3f, 0x3f }; diff --git a/openbox/theme/iconify.xbm b/openbox/theme/iconify.xbm new file mode 100644 index 0000000..6799d2e --- /dev/null +++ b/openbox/theme/iconify.xbm @@ -0,0 +1,4 @@ +#define iconify_width 6 +#define iconify_height 6 +static unsigned char iconify_bits[] = { + 0x00, 0x00, 0x00, 0x00, 0x0c, 0x0c }; diff --git a/openbox/theme/max.xbm b/openbox/theme/max.xbm new file mode 100644 index 0000000..4426c02 --- /dev/null +++ b/openbox/theme/max.xbm @@ -0,0 +1,4 @@ +#define max7_width 6 +#define max7_height 6 +static unsigned char max7_bits[] = { + 0x33, 0x33, 0x00, 0x00, 0x30, 0x30 }; diff --git a/openbox/theme/max_disabled.xbm b/openbox/theme/max_disabled.xbm new file mode 100644 index 0000000..a6eab63 --- /dev/null +++ b/openbox/theme/max_disabled.xbm @@ -0,0 +1,4 @@ +#define max_disabled_width 6 +#define max_disabled_height 6 +static unsigned char max_disabled_bits[] = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 }; diff --git a/openbox/theme/max_toggled.xbm b/openbox/theme/max_toggled.xbm new file mode 100644 index 0000000..e0f5022 --- /dev/null +++ b/openbox/theme/max_toggled.xbm @@ -0,0 +1,4 @@ +#define iconify2_width 6 +#define iconify2_height 6 +static unsigned char iconify2_bits[] = { + 0x03, 0x03, 0x00, 0x00, 0x33, 0x33 }; diff --git a/openbox/theme/shade.xbm b/openbox/theme/shade.xbm new file mode 100644 index 0000000..7438e1f --- /dev/null +++ b/openbox/theme/shade.xbm @@ -0,0 +1,4 @@ +#define shade_width 6 +#define shade_height 6 +static unsigned char shade_bits[] = { + 0x0c, 0x0c, 0x00, 0x00, 0x00, 0x00 }; diff --git a/openbox/theme/themerc b/openbox/theme/themerc new file mode 100644 index 0000000..20f2425 --- /dev/null +++ b/openbox/theme/themerc @@ -0,0 +1,138 @@ +# Arc Openbox theme +# Copyright (C) 2015 Dino Duratović +# +# Inspired by and made for horst3180's Arc GTK theme +# https://github.com/horst3180/Arc-theme +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +### WINDOW +border.width: 1 + +window.client.padding.width: 0 +window.client.padding.height: 0 +window.handle.width: 4 + +padding.width: 6 +padding.height: 5 + +window.active.border.color: #0b0b0b +window.inactive.border.color: #070707 +window.active.title.separator.color: #000000 +window.inactive.title.separator.color: #000000 +window.active.client.color: #000000 +window.inactive.client.color: #000000 + +window.active.label.text.color: #D3DAE3 +window.inactive.label.text.color: #7F8388 + +window.active.button.unpressed.image.color: #D3DAE3 +window.active.button.pressed.image.color: #DC143C +window.active.button.disabled.image.color: #000000 +window.active.button.hover.image.color: #afb8c5 +window.active.button.toggled.unpressed.image.color: #D3DAE3 +window.active.button.toggled.pressed.image.color: #DC143C +window.active.button.toggled.hover.image.color: #afb8c5 +window.inactive.button.unpressed.image.color: #1F2328 +window.inactive.button.pressed.image.color: #DC143C +window.inactive.button.disabled.image.color: #000000 +window.inactive.button.hover.image.color: #afb8c5 +window.inactive.button.toggled.unpressed.image.color: #1F2328 +window.inactive.button.toggled.pressed.image.color: #DC143C +window.inactive.button.toggled.hover.image.color: #afb8c5 + +window.active.title.bg: flat solid +window.active.title.bg.color: #000000 +window.active.label.bg: flat solid +window.active.label.bg.color: #000000 +window.active.handle.bg: flat solid +window.active.handle.bg.color: #000000 +window.active.grip.bg: flat solid +window.active.grip.bg.color: #000000 +window.inactive.title.bg: flat solid +window.inactive.title.bg.color: #000000 +window.inactive.label.bg: flat solid +window.inactive.label.bg.color: #000000 +window.inactive.handle.bg: flat solid +window.inactive.handle.bg.color: #000000 +window.inactive.grip.bg: flat solid +window.inactive.grip.bg.color: #000000 + +window.active.button.unpressed.bg: parentrelative +window.active.button.pressed.bg: parentrelative +window.active.button.hover.bg: parentrelative +window.active.button.disabled.bg: parentrelative +window.active.button.toggled.unpressed.bg: parentrelative +window.active.button.toggled.pressed.bg: parentrelative +window.active.button.toggled.hover.bg: parentrelative +window.inactive.button.unpressed.bg: parentrelative +window.inactive.button.pressed.bg: parentrelative +window.inactive.button.hover.bg: parentrelative +window.inactive.button.disabled.bg: parentrelative +window.inactive.button.toggled.unpressed.bg: parentrelative +window.inactive.button.toggled.pressed.bg: parentrelative +window.inactive.button.toggled.hover.bg: parentrelative + +window.label.text.justify: center + +#window.active.label.text.font: text shadow +#window.inactive.label.text.font: text shadow + +### MENU +menu.border.width: 8 +menu.separator.width: 1 +menu.separator.padding.width: 6 +menu.separator.padding.height: 4 + +menu.overlap.x: 0 +menu.overlap.y: 0 + +menu.border.color: #000000 +menu.separator.color: #222222 + +menu.title.text.color: #ffffff +menu.items.text.color: #a8adb5 +menu.items.disabled.text.color: #76797F +menu.items.active.text.color: #000000 +menu.items.active.disabled.text.color: #aeb0b6 + +menu.items.bg: flat solid +menu.items.bg.color: #000000 +menu.items.active.bg: flat solid +menu.items.active.bg.color: #ffffff +menu.title.bg: flat solid +menu.title.bg.color: #000000 + +menu.title.text.justify: center + +#menu.items.font: text shadow +#menu.title.text.font: text shadow + +### OSD +osd.border.width: 1 + +osd.border.color: #000000 + +osd.label.text.color: #D3DAE3 + +osd.bg: flat solid +osd.bg.color: #000000 +osd.label.bg: flat solid +osd.label.bg.color: #000000 +osd.hilight.bg: flat solid +osd.hilight.bg.color: #DC143C +osd.unhilight.bg: flat solid +osd.unhilight.bg.color: #000000 + +#osd.label.text.font: text shadow diff --git a/shell/agent-runtime-login b/shell/agent-runtime-login new file mode 100755 index 0000000..e9d97f1 --- /dev/null +++ b/shell/agent-runtime-login @@ -0,0 +1,238 @@ +#!/bin/bash +# Choose and run a supported authentication flow for an agent runtime. + +set -euo pipefail + +codex_command="${CODEX_COMMAND:-codex}" +claude_command="${CLAUDE_COMMAND:-claude}" +goose_command="${GOOSE_COMMAND:-goose}" + +style_reset="" +style_bold="" +style_dim="" +style_accent="" +style_info="" +style_success="" +style_error="" + +if { [ -t 1 ] || [ -t 2 ] || [ "${FORCE_COLOR:-}" = "1" ]; } && + { [ "${TERM:-dumb}" != "dumb" ] || [ "${FORCE_COLOR:-}" = "1" ]; } && + [ -z "${NO_COLOR:-}" ]; then + style_reset=$'\033[0m' + style_bold=$'\033[1m' + style_dim=$'\033[2m' + style_accent=$'\033[38;2;215;215;46m' + style_info=$'\033[38;5;75m' + style_success=$'\033[38;5;78m' + style_error=$'\033[38;5;203m' +fi + +print_heading() { + printf '\n%s%s%s%s\n' "$style_bold" "$style_accent" "$1" "$style_reset" + printf '%s────────────────────────────────────────────────────────────%s\n' \ + "$style_dim" "$style_reset" +} + +print_info() { + printf '%s→%s %s\n' "$style_info" "$style_reset" "$1" +} + +print_success() { + printf '%s✓%s %s\n' "$style_success" "$style_reset" "$1" +} + +print_error() { + printf '%s×%s %s\n' "$style_error" "$style_reset" "$1" >&2 +} + +usage() { + cat <<'EOF' +Agent runtime authentication + +Usage: + agent-runtime-login codex [device|browser|api-key|status] + agent-runtime-login claude [subscription|console|setup-token|sso|status] + agent-runtime-login goose [configure|info] + +With no authentication method, an interactive chooser explains the available +flows. Codex supports a real device-code flow. Claude Code currently offers +browser OAuth or a long-lived setup token rather than device-code login. +EOF +} + +require_command() { + local command_name="$1" + if ! command -v "$command_name" >/dev/null 2>&1; then + print_error "$command_name is not installed." + return 1 + fi +} + +choose_codex_method() { + local default_method="${CODEX_LOGIN_DEFAULT:-device}" + local default_choice=1 + local choice + + if [ "$default_method" = "browser" ]; then + default_choice=2 + fi + + print_heading "Sign in to Codex" >&2 + print_info "Choose where you want to complete authentication." >&2 + printf '1. Device code (recommended for remote desktops)\n' >&2 + printf '2. Browser login (opens the browser on this desktop)\n' >&2 + printf '3. OpenAI API key (input hidden)\n' >&2 + printf '4. Check current login status\n' >&2 + read -r -p "${style_bold}${style_accent}Method [$default_choice]: ${style_reset}" \ + choice + choice="${choice:-$default_choice}" + + case "$choice" in + 1) printf 'device\n' ;; + 2) printf 'browser\n' ;; + 3) printf 'api-key\n' ;; + 4) printf 'status\n' ;; + *) + print_error "Choose 1, 2, 3, or 4." + return 1 + ;; + esac +} + +login_codex() { + local method="${1:-}" + local api_key + + require_command "$codex_command" + if [ -z "$method" ]; then + method="$(choose_codex_method)" + fi + + case "$method" in + device) + print_info "Codex will show a URL and one-time code for another device." + "$codex_command" login --device-auth + ;; + browser) + print_info "Opening the Codex login in this desktop's browser." + "$codex_command" login + ;; + api-key) + print_heading "Sign in to Codex with an API key" + print_info "The key is read securely and is not added to shell history." + read -r -s -p "${style_bold}${style_accent}OpenAI API key: ${style_reset}" \ + api_key + printf '\n' + if [ -z "$api_key" ]; then + print_error "An API key is required." + return 1 + fi + printf '%s' "$api_key" | "$codex_command" login --with-api-key + unset api_key + ;; + status) + "$codex_command" login status + ;; + *) + print_error "Unknown Codex authentication method: $method" + return 1 + ;; + esac +} + +choose_claude_method() { + local choice + + print_heading "Sign in to Claude Code" >&2 + print_info "Claude Code does not currently expose device-code authentication." >&2 + printf '1. Claude subscription (browser)\n' >&2 + printf '2. Anthropic Console account (browser)\n' >&2 + printf '3. Long-lived setup token (remote/headless alternative)\n' >&2 + printf '4. Organization SSO (browser)\n' >&2 + printf '5. Check current authentication status\n' >&2 + read -r -p "${style_bold}${style_accent}Method [1]: ${style_reset}" choice + choice="${choice:-1}" + + case "$choice" in + 1) printf 'subscription\n' ;; + 2) printf 'console\n' ;; + 3) printf 'setup-token\n' ;; + 4) printf 'sso\n' ;; + 5) printf 'status\n' ;; + *) + print_error "Choose 1, 2, 3, 4, or 5." + return 1 + ;; + esac +} + +login_claude() { + local method="${1:-}" + + require_command "$claude_command" + if [ -z "$method" ]; then + method="$(choose_claude_method)" + fi + + case "$method" in + subscription) + print_info "Opening Claude subscription login in this desktop's browser." + "$claude_command" auth login --claudeai + ;; + console) + print_info "Opening Anthropic Console login in this desktop's browser." + "$claude_command" auth login --console + ;; + setup-token) + print_info "Starting Claude Code's long-lived setup-token flow." + "$claude_command" setup-token + ;; + sso) + print_info "Opening your organization's Claude SSO login." + "$claude_command" auth login --sso + ;; + status) + "$claude_command" auth status + ;; + *) + print_error "Unknown Claude authentication method: $method" + return 1 + ;; + esac +} + +configure_goose() { + local method="${1:-configure}" + + require_command "$goose_command" + case "$method" in + configure) "$goose_command" configure ;; + info) "$goose_command" info ;; + *) + print_error "Unknown Goose setup method: $method" + return 1 + ;; + esac +} + +runtime="${1:-}" +if [ "$#" -gt 0 ]; then + shift +fi + +case "$runtime" in + codex) login_codex "$@" ;; + claude) login_claude "$@" ;; + goose) configure_goose "$@" ;; + help|-h|--help|"") + usage + exit 0 + ;; + *) + print_error "Unknown runtime: $runtime" + usage >&2 + exit 1 + ;; +esac + +print_success "Runtime authentication command finished." diff --git a/shell/bashrc b/shell/bashrc new file mode 100644 index 0000000..5d55805 --- /dev/null +++ b/shell/bashrc @@ -0,0 +1,39 @@ +# Buzznode - custom bash prompt and shell settings. + +__buzznode_ps1() { + local exit_code=$? + local yellow='\[\e[38;2;215;215;46m\]' + local blue='\[\e[1;34m\]' + local cyan='\[\e[0;36m\]' + local red='\[\e[1;31m\]' + local reset='\[\e[0m\]' + local arrow + + if [ "$exit_code" -ne 0 ]; then + arrow='\[\e[1;31m\]➜' + else + arrow="${reset}➜" + fi + + local branch + branch="$(git --no-optional-locks symbolic-ref --short HEAD 2>/dev/null \ + || git --no-optional-locks rev-parse --short HEAD 2>/dev/null)" + + local git_info="" + if [ -n "$branch" ]; then + git_info=" ${cyan}(${red}${branch}${cyan})" + fi + + PS1="${yellow}@buzznode ${arrow} ${blue}\\w${git_info} ${reset}\$ " +} + +PROMPT_COMMAND="__buzznode_ps1" + +export EDITOR=vim +export LANG=C.UTF-8 +export BROWSER=chromium + +if [ -f "$HOME/.config/buzznode/environment" ]; then + # shellcheck disable=SC1091 + source "$HOME/.config/buzznode/environment" +fi diff --git a/shell/buzznode b/shell/buzznode new file mode 100755 index 0000000..3184a68 --- /dev/null +++ b/shell/buzznode @@ -0,0 +1,816 @@ +#!/bin/bash +# Configure and operate one headless Buzz agent on a Buzznode desktop. + +set -euo pipefail + +config_dir="${BUZZNODE_CONFIG_DIR:-$HOME/.config/buzznode}" +environment_file="$config_dir/environment" +runtime_file="$config_dir/runtime" +harness_log="${BUZZNODE_HARNESS_LOG:-/var/log/buzznode/buzz-acp.log}" + +style_reset="" +style_bold="" +style_dim="" +style_accent="" +style_info="" +style_success="" +style_warning="" +style_error="" + +if { [ -t 1 ] || [ -t 2 ] || [ "${FORCE_COLOR:-}" = "1" ]; } && + { [ "${TERM:-dumb}" != "dumb" ] || [ "${FORCE_COLOR:-}" = "1" ]; } && + [ -z "${NO_COLOR:-}" ]; then + style_reset=$'\033[0m' + style_bold=$'\033[1m' + style_dim=$'\033[2m' + style_accent=$'\033[38;2;215;215;46m' + style_info=$'\033[38;5;75m' + style_success=$'\033[38;5;78m' + style_warning=$'\033[38;5;214m' + style_error=$'\033[38;5;203m' +fi + +print_heading() { + printf '\n%s%s%s%s\n' "$style_bold" "$style_accent" "$1" "$style_reset" + printf '%s────────────────────────────────────────────────────────────%s\n' \ + "$style_dim" "$style_reset" +} + +print_info() { + printf '%s→%s %s\n' "$style_info" "$style_reset" "$1" +} + +print_success() { + printf '%s✓%s %s\n' "$style_success" "$style_reset" "$1" +} + +print_warning() { + printf '%s!%s %s\n' "$style_warning" "$style_reset" "$1" >&2 +} + +print_error() { + printf '%s×%s %s\n' "$style_error" "$style_reset" "$1" >&2 +} + +doctor_ok() { + printf '%s[ok]%s %s\n' "$style_success" "$style_reset" "$1" +} + +doctor_fail() { + printf '%s[fail]%s %s\n' "$style_error" "$style_reset" "$1" +} + +usage() { + cat <<'EOF' +Buzznode - one persistent computer for one Buzz agent + +Usage: + buzznode setup + buzznode configure --relay-url --private-key + [--auth-tag ] [--api-token ] + [--respond-to anyone|owner-only|allowlist|nobody] + [--respond-to-allowlist ] + [--agent-owner <64-character-hex-pubkey>] + [--runtime codex|claude|goose] + buzznode configure --enrollment-stdin [--api-token ] + [--runtime codex|claude|goose] + buzznode configured + buzznode launch + buzznode start + buzznode stop + buzznode restart + buzznode status + buzznode doctor + buzznode runtime-login [codex|claude|goose] [authentication-method] + buzznode logs + +Use "Create New Agent for Buzznode" in Buzzbox, or stop an existing agent and +use "Move Existing Agent to Buzznode", to obtain an enrollment bundle. Node +setup stores the imported settings and optional relay API token in +~/.config/buzznode/environment with mode 0600. Buzznode runs buzz-acp directly; +it does not contain or launch Buzz Desktop. +EOF +} + +validate_relay_url() { + local relay_url="$1" + [[ "$relay_url" =~ ^wss?://[^[:space:]]+$ ]] +} + +validate_private_key() { + local private_key="$1" + [[ "$private_key" =~ ^nsec1[[:alnum:]]+$ || "$private_key" =~ ^[[:xdigit:]]{64}$ ]] +} + +validate_pubkey_list() { + local pubkey_list="$1" + local pubkey + local -a pubkeys=() + + [ -n "$pubkey_list" ] || return 1 + IFS=',' read -r -a pubkeys <<<"$pubkey_list" + for pubkey in "${pubkeys[@]}"; do + [[ "$pubkey" =~ ^[[:xdigit:]]{64}$ ]] || return 1 + done +} + +parse_enrollment_bundle() { + local bundle="$1" + local encoded + local decoded + + if [[ "$bundle" != buzznode-v1:* ]]; then + print_error "Enrollment bundle must start with buzznode-v1:." + return 1 + fi + encoded="${bundle#buzznode-v1:}" + decoded="$(printf '%s' "$encoded" | base64 --decode 2>/dev/null)" || { + print_error "Enrollment bundle is not valid base64." + return 1 + } + if ! jq -e ' + .version == 1 and + (.name | type == "string") and + (.relay_url | type == "string") and + (.private_key | type == "string") and + ((.auth_tag // "") | type == "string") and + (.respond_to == "anyone" or + .respond_to == "owner-only" or + .respond_to == "allowlist" or + .respond_to == "nobody") and + ((.respond_to_allowlist // []) | type == "array") and + all((.respond_to_allowlist // [])[]; type == "string") + ' >/dev/null 2>&1 <<<"$decoded"; then + print_error "Enrollment bundle has an unsupported or incomplete payload." + return 1 + fi + + enrollment_name="$(jq -r '.name' <<<"$decoded")" + enrollment_relay_url="$(jq -r '.relay_url' <<<"$decoded")" + enrollment_private_key="$(jq -r '.private_key' <<<"$decoded")" + enrollment_auth_tag="$(jq -r '.auth_tag // ""' <<<"$decoded")" + enrollment_respond_to="$(jq -r '.respond_to' <<<"$decoded")" + enrollment_respond_to_allowlist="$( + jq -r '(.respond_to_allowlist // []) | join(",")' <<<"$decoded" + )" + + if ! validate_relay_url "$enrollment_relay_url"; then + print_error "Enrollment bundle contains an invalid relay URL." + return 1 + fi + if ! validate_private_key "$enrollment_private_key"; then + print_error "Enrollment bundle contains an invalid agent private key." + return 1 + fi + case "$enrollment_respond_to" in + anyone|nobody) ;; + owner-only) + if [ -z "$enrollment_auth_tag" ]; then + print_error "Enrollment bundle has no owner authorization." + return 1 + fi + ;; + allowlist) + if ! validate_pubkey_list "$enrollment_respond_to_allowlist"; then + print_error "Enrollment bundle contains an invalid response allowlist." + return 1 + fi + ;; + esac +} + +shell_quote() { + printf '%q' "$1" +} + +load_environment() { + if [ -f "$environment_file" ]; then + # shellcheck disable=SC1090 + source "$environment_file" + fi +} + +effective_relay_url() { + load_environment + printf '%s' "${BUZZ_RELAY_URL:-}" +} + +write_configuration() { + local relay_url="$1" + local private_key="$2" + local auth_tag="$3" + local api_token="$4" + local respond_to="$5" + local respond_to_allowlist="$6" + local agent_owner="$7" + local runtime="$8" + local agent_command + local agent_args + local mcp_command="" + local temporary_file + + if ! validate_relay_url "$relay_url"; then + print_error "Relay URL must start with ws:// or wss:// and contain no spaces." + return 1 + fi + + if ! validate_private_key "$private_key"; then + print_error "Agent private key must be an nsec1 value or a 64-character hexadecimal key." + return 1 + fi + + case "$respond_to" in + anyone|nobody) ;; + owner-only) + if [ -z "$auth_tag" ] && + [[ ! "$agent_owner" =~ ^[[:xdigit:]]{64}$ ]]; then + print_error "Owner-only mode requires an authorization tag or a 64-character owner public key." + return 1 + fi + ;; + allowlist) + if ! validate_pubkey_list "$respond_to_allowlist"; then + print_error "Allowlist mode requires one or more comma-separated 64-character public keys." + return 1 + fi + ;; + *) + print_error "Response policy must be anyone, owner-only, allowlist, or nobody." + return 1 + ;; + esac + + case "$runtime" in + codex) + agent_command="codex-acp" + agent_args="" + mcp_command="buzz-dev-mcp" + ;; + claude) + agent_command="claude-agent-acp" + agent_args="" + ;; + goose) + agent_command="goose" + agent_args="acp" + ;; + *) + print_error "Runtime must be codex, claude, or goose." + return 1 + ;; + esac + + mkdir -p "$config_dir" + chmod 700 "$config_dir" + temporary_file="$(mktemp "$config_dir/environment.XXXXXX")" + chmod 600 "$temporary_file" + + { + printf 'export BUZZ_RELAY_URL=%s\n' "$(shell_quote "$relay_url")" + printf 'export BUZZ_PRIVATE_KEY=%s\n' "$(shell_quote "$private_key")" + if [ -n "$auth_tag" ]; then + printf 'export BUZZ_AUTH_TAG=%s\n' "$(shell_quote "$auth_tag")" + else + printf 'unset BUZZ_AUTH_TAG\n' + fi + if [ -n "$api_token" ]; then + printf 'export BUZZ_API_TOKEN=%s\n' "$(shell_quote "$api_token")" + else + printf 'unset BUZZ_API_TOKEN\n' + fi + printf 'export BUZZ_ACP_RESPOND_TO=%s\n' "$(shell_quote "$respond_to")" + if [ -n "$respond_to_allowlist" ]; then + printf 'export BUZZ_ACP_RESPOND_TO_ALLOWLIST=%s\n' \ + "$(shell_quote "$respond_to_allowlist")" + else + printf 'unset BUZZ_ACP_RESPOND_TO_ALLOWLIST\n' + fi + if [ -n "$agent_owner" ]; then + printf 'export BUZZ_ACP_AGENT_OWNER=%s\n' "$(shell_quote "$agent_owner")" + else + printf 'unset BUZZ_ACP_AGENT_OWNER\n' + fi + printf 'export BUZZNODE_RUNTIME=%s\n' "$(shell_quote "$runtime")" + printf 'export BUZZ_ACP_AGENT_COMMAND=%s\n' "$(shell_quote "$agent_command")" + printf 'export BUZZ_ACP_AGENT_ARGS=%s\n' "$(shell_quote "$agent_args")" + if [ -n "$mcp_command" ]; then + printf 'export BUZZ_ACP_MCP_COMMAND=%s\n' "$(shell_quote "$mcp_command")" + else + printf 'unset BUZZ_ACP_MCP_COMMAND\n' + fi + } > "$temporary_file" + + mv "$temporary_file" "$environment_file" + chmod 600 "$environment_file" + printf '%s\n' "$runtime" > "$runtime_file" + chmod 600 "$runtime_file" +} + +configure_command() { + local relay_url="" + local private_key="" + local auth_tag="" + local api_token="" + local respond_to="anyone" + local respond_to_allowlist="" + local agent_owner="" + local runtime="codex" + local enrollment_stdin=false + local enrollment_bundle + local enrollment_name="" + local enrollment_relay_url="" + local enrollment_private_key="" + local enrollment_auth_tag="" + local enrollment_respond_to="" + local enrollment_respond_to_allowlist="" + + while [ "$#" -gt 0 ]; do + case "$1" in + --relay-url) + relay_url="${2:-}" + shift 2 + ;; + --private-key) + private_key="${2:-}" + shift 2 + ;; + --auth-tag) + auth_tag="${2:-}" + shift 2 + ;; + --api-token) + api_token="${2:-}" + shift 2 + ;; + --respond-to) + respond_to="${2:-}" + shift 2 + ;; + --respond-to-allowlist) + respond_to_allowlist="${2:-}" + shift 2 + ;; + --agent-owner) + agent_owner="${2:-}" + shift 2 + ;; + --enrollment-stdin) + enrollment_stdin=true + shift + ;; + --runtime) + runtime="${2:-}" + shift 2 + ;; + *) + print_error "Unknown configure option: $1" + usage >&2 + return 1 + ;; + esac + done + + if [ "$enrollment_stdin" = true ]; then + IFS= read -r enrollment_bundle || true + if [ -z "$enrollment_bundle" ]; then + print_error "No enrollment bundle was received on standard input." + return 1 + fi + parse_enrollment_bundle "$enrollment_bundle" + relay_url="$enrollment_relay_url" + private_key="$enrollment_private_key" + auth_tag="$enrollment_auth_tag" + respond_to="$enrollment_respond_to" + respond_to_allowlist="$enrollment_respond_to_allowlist" + fi + + if [ -z "$relay_url" ]; then + print_error "--relay-url is required." + return 1 + fi + if [ -z "$private_key" ]; then + print_error "--private-key is required." + return 1 + fi + + write_configuration "$relay_url" "$private_key" "$auth_tag" "$api_token" \ + "$respond_to" "$respond_to_allowlist" "$agent_owner" "$runtime" + print_success "Buzznode configured for $relay_url using the $runtime runtime." +} + +setup_command() { + local current_relay + local relay_url + local private_key="" + local auth_tag="" + local api_token="" + local respond_choice + local respond_to + local respond_to_allowlist="" + local agent_owner="" + local runtime_choice + local runtime + local login_choice + local enrollment_bundle="" + local enrollment_name="" + local enrollment_relay_url="" + local enrollment_private_key="" + local enrollment_auth_tag="" + local enrollment_respond_to="" + local enrollment_respond_to_allowlist="" + + current_relay="$(effective_relay_url)" + + clear + welcome --no-shell + print_heading "Connect this Buzznode" + print_info "This node runs one agent from an existing Buzz workspace." + print_info "In Buzzbox, choose Agent Setup → Create New Agent for Buzznode." + echo + + read -r -s -p "${style_bold}${style_accent}Buzznode enrollment bundle ${style_reset}${style_dim}(recommended, input hidden; Enter for manual)${style_reset}${style_bold}${style_accent}: ${style_reset}" \ + enrollment_bundle + echo + if [ -n "$enrollment_bundle" ]; then + parse_enrollment_bundle "$enrollment_bundle" + relay_url="$enrollment_relay_url" + private_key="$enrollment_private_key" + auth_tag="$enrollment_auth_tag" + respond_to="$enrollment_respond_to" + respond_to_allowlist="$enrollment_respond_to_allowlist" + print_success "Enrollment bundle accepted." + printf ' %sAgent:%s %s\n' \ + "$style_bold" "$style_reset" "$enrollment_name" + printf ' %sRelay:%s %s\n' \ + "$style_bold" "$style_reset" "$relay_url" + printf ' %sPolicy:%s %s\n' \ + "$style_bold" "$style_reset" "$respond_to" + echo + else + print_heading "Manual connection" + if [ -n "$current_relay" ]; then + read -r -p "${style_bold}${style_accent}Buzz relay URL [$current_relay]: ${style_reset}" \ + relay_url + relay_url="${relay_url:-$current_relay}" + else + read -r -p "${style_bold}${style_accent}Buzz relay URL (ws:// or wss://): ${style_reset}" \ + relay_url + fi + + while ! validate_relay_url "$relay_url"; do + print_warning "Enter a valid ws:// or wss:// relay URL." + read -r -p "${style_bold}${style_accent}Buzz relay URL: ${style_reset}" \ + relay_url + done + + while ! validate_private_key "$private_key"; do + read -r -s -p "${style_bold}${style_accent}Agent private key ${style_reset}${style_dim}(nsec1 or hex, input hidden)${style_reset}${style_bold}${style_accent}: ${style_reset}" \ + private_key + echo + if ! validate_private_key "$private_key"; then + print_warning "Enter the private key revealed when the agent was created." + fi + done + + read -r -s -p "${style_bold}${style_accent}Agent authorization tag ${style_reset}${style_dim}(optional, input hidden)${style_reset}${style_bold}${style_accent}: ${style_reset}" \ + auth_tag + echo + print_heading "Response policy" + printf '%sWho may activate this agent?%s\n' "$style_bold" "$style_reset" + echo " 1. Anyone with access to one of its Buzz channels" + echo " 2. Only its owner (requires the authorization tag above)" + echo " 3. Only listed public keys" + echo " 4. Nobody (keep the harness connected but inactive)" + read -r -p "${style_bold}${style_accent}Response policy [1]: ${style_reset}" \ + respond_choice + case "${respond_choice:-1}" in + 1) respond_to="anyone" ;; + 2) + respond_to="owner-only" + if [ -z "$auth_tag" ]; then + read -r -p "${style_bold}${style_accent}Owner public key (64-character hex): ${style_reset}" \ + agent_owner + fi + ;; + 3) + respond_to="allowlist" + read -r -p "${style_bold}${style_accent}Allowed public keys (comma-separated hex): ${style_reset}" \ + respond_to_allowlist + ;; + 4) respond_to="nobody" ;; + *) + print_error "Unknown selection." + return 1 + ;; + esac + fi + + read -r -s -p "${style_bold}${style_accent}Relay API token ${style_reset}${style_dim}(optional, input hidden)${style_reset}${style_bold}${style_accent}: ${style_reset}" \ + api_token + echo + print_heading "Agent runtime" + printf '%sChoose the runtime for this node:%s\n' "$style_bold" "$style_reset" + echo " 1. Codex" + echo " 2. Claude Code" + echo " 3. Goose" + read -r -p "${style_bold}${style_accent}Runtime [1]: ${style_reset}" \ + runtime_choice + case "${runtime_choice:-1}" in + 1) runtime="codex" ;; + 2) runtime="claude" ;; + 3) runtime="goose" ;; + *) + print_error "Unknown selection." + return 1 + ;; + esac + + write_configuration "$relay_url" "$private_key" "$auth_tag" "$api_token" \ + "$respond_to" "$respond_to_allowlist" "$agent_owner" "$runtime" + echo + print_success "Agent connection settings saved securely." + echo + read -r -p "${style_bold}${style_accent}Configure $runtime now? [Y/n]: ${style_reset}" \ + login_choice + if [[ ! "${login_choice:-y}" =~ ^[Nn]$ ]]; then + runtime_login_command "$runtime" + fi + + if [ -n "${DISPLAY:-}" ]; then + start_command + fi + + print_heading "Buzznode setup complete" + if [ -n "$enrollment_name" ]; then + print_success "The node is configured for $enrollment_name." + else + print_success "The node connection is configured." + fi + echo + printf '%sNext:%s\n' "$style_bold" "$style_reset" + echo " 1. In Buzzbox, make sure this agent belongs to the intended channel." + echo " 2. Run 'buzznode start' to connect the agent harness." + echo " 3. Mention the agent in that Buzz channel." + echo + print_info "Run 'buzznode doctor' at any time to inspect this node." + + if [ -t 0 ] && [ -t 1 ]; then + echo + read -r -p "${style_bold}${style_accent}Press Enter to continue to the Buzznode terminal. ${style_reset}" + fi +} + +configured_command() { + load_environment + [ -n "${BUZZ_RELAY_URL:-}" ] && + validate_relay_url "$BUZZ_RELAY_URL" && + [ -n "${BUZZ_PRIVATE_KEY:-}" ] && + validate_private_key "$BUZZ_PRIVATE_KEY" +} + +harness_running() { + pgrep -f '(^|/)buzz-acp($| )' >/dev/null 2>&1 +} + +launch_command() { + load_environment + + if ! configured_command; then + print_error "Buzznode is not configured. Run: buzznode setup" + return 1 + fi + + if harness_running; then + print_success "Agent harness is already running." + return 0 + fi + + export BUZZ_RELAY_URL BUZZ_PRIVATE_KEY + cd /workspace + exec buzz-acp +} + +stop_command() { + if harness_running; then + pkill -TERM -f '(^|/)buzz-acp($| )' + print_info "Agent harness stopping." + else + print_warning "Agent harness is already stopped." + fi +} + +start_command() { + if harness_running; then + print_success "Agent harness is already running." + return 0 + fi + if ! configured_command; then + print_error "Buzznode is not configured. Run: buzznode setup" + return 1 + fi + mkdir -p "$(dirname "$harness_log")" + touch "$harness_log" + buzznode launch >>"$harness_log" 2>&1 & + disown + print_info "Agent harness starting." +} + +status_command() { + local relay_url + local runtime + + load_environment + relay_url="${BUZZ_RELAY_URL:-}" + runtime="${BUZZNODE_RUNTIME:-}" + if [ -z "$runtime" ] && [ -f "$runtime_file" ]; then + runtime="$(tr -d '[:space:]' < "$runtime_file")" + fi + + print_heading "Buzznode status" + printf 'Relay: %s\n' "${relay_url:-not configured}" + printf 'Agent key: %s\n' "$([ -n "${BUZZ_PRIVATE_KEY:-}" ] && echo configured || echo 'not configured')" + printf 'Authorization: %s\n' "$([ -n "${BUZZ_AUTH_TAG:-}" ] && echo configured || echo 'not configured')" + printf 'Respond to: %s\n' "${BUZZ_ACP_RESPOND_TO:-not configured}" + if [ "${BUZZ_ACP_RESPOND_TO:-}" = "allowlist" ]; then + printf 'Allowlist: %s\n' \ + "$([ -n "${BUZZ_ACP_RESPOND_TO_ALLOWLIST:-}" ] && echo configured || echo 'not configured')" + fi + printf 'Runtime: %s\n' "${runtime:-not selected}" + if harness_running; then + printf 'Agent harness: %srunning%s\n' "$style_success" "$style_reset" + else + printf 'Agent harness: %sstopped%s\n' "$style_warning" "$style_reset" + fi +} + +doctor_command() { + local failures=0 + local relay_http_url + local relay_url + local runtime + local command + + print_heading "Buzznode diagnostics" + load_environment + relay_url="${BUZZ_RELAY_URL:-}" + runtime="${BUZZNODE_RUNTIME:-}" + + if [ -n "$relay_url" ] && validate_relay_url "$relay_url"; then + doctor_ok "relay URL: $relay_url" + case "$relay_url" in + wss://*) relay_http_url="https://${relay_url#wss://}" ;; + ws://*) relay_http_url="http://${relay_url#ws://}" ;; + esac + if curl --silent --show-error --output /dev/null \ + --connect-timeout 5 --max-time 8 "$relay_http_url"; then + doctor_ok "relay is reachable" + else + doctor_fail "relay is not reachable" + failures=$((failures + 1)) + fi + else + doctor_fail "relay URL is not configured" + failures=$((failures + 1)) + fi + + if [ -n "${BUZZ_PRIVATE_KEY:-}" ] && validate_private_key "$BUZZ_PRIVATE_KEY"; then + doctor_ok "agent private key is configured" + else + doctor_fail "agent private key is not configured" + failures=$((failures + 1)) + fi + + case "${BUZZ_ACP_RESPOND_TO:-}" in + anyone) + doctor_ok "response policy: anyone" + ;; + nobody) + doctor_ok "response policy: nobody" + ;; + owner-only) + if [ -n "${BUZZ_AUTH_TAG:-}" ] || + [[ "${BUZZ_ACP_AGENT_OWNER:-}" =~ ^[[:xdigit:]]{64}$ ]]; then + doctor_ok "response policy: owner-only" + else + doctor_fail "owner-only response policy has no owner credential" + failures=$((failures + 1)) + fi + ;; + allowlist) + if validate_pubkey_list "${BUZZ_ACP_RESPOND_TO_ALLOWLIST:-}"; then + doctor_ok "response policy: allowlist" + else + doctor_fail "response allowlist is missing or invalid" + failures=$((failures + 1)) + fi + ;; + *) + doctor_fail "response policy is not configured" + failures=$((failures + 1)) + ;; + esac + + for command in buzz buzz-acp buzz-agent buzz-dev-mcp codex codex-acp \ + claude claude-agent-acp goose; do + if command -v "$command" >/dev/null 2>&1; then + doctor_ok "$command" + else + doctor_fail "$command is not installed" + failures=$((failures + 1)) + fi + done + + case "$runtime" in + codex) + [ "${BUZZ_ACP_AGENT_COMMAND:-}" = "codex-acp" ] || failures=$((failures + 1)) + ;; + claude) + [ "${BUZZ_ACP_AGENT_COMMAND:-}" = "claude-agent-acp" ] || failures=$((failures + 1)) + ;; + goose) + [ "${BUZZ_ACP_AGENT_COMMAND:-}" = "goose" ] || failures=$((failures + 1)) + ;; + *) + doctor_fail "agent runtime is not configured" + failures=$((failures + 1)) + ;; + esac + + if [ -w /workspace ]; then + doctor_ok "/workspace is writable" + else + doctor_fail "/workspace is not writable" + failures=$((failures + 1)) + fi + + if [ -f "$environment_file" ]; then + local mode + mode="$(stat -c '%a' "$environment_file" 2>/dev/null || stat -f '%Lp' "$environment_file")" + if [ "$mode" = "600" ]; then + doctor_ok "node configuration is mode 0600" + else + doctor_fail "node configuration mode is $mode, expected 600" + failures=$((failures + 1)) + fi + fi + + status_command + [ "$failures" -eq 0 ] +} + +runtime_login_command() { + local runtime="${1:-}" + + if [ -n "$runtime" ]; then + shift + else + load_environment + runtime="${BUZZNODE_RUNTIME:-codex}" + fi + + case "$runtime" in + codex|claude|goose) + CODEX_LOGIN_DEFAULT="${CODEX_LOGIN_DEFAULT:-device}" \ + agent-runtime-login "$runtime" "$@" + ;; + *) + print_error "Unknown runtime: $runtime" + return 1 + ;; + esac +} + +logs_command() { + mkdir -p "$(dirname "$harness_log")" + touch "$harness_log" + tail --lines=200 --follow "$harness_log" +} + +command="${1:-help}" +if [ "$#" -gt 0 ]; then + shift +fi + +case "$command" in + setup) setup_command "$@" ;; + configure) configure_command "$@" ;; + configured) configured_command ;; + launch) launch_command ;; + start) start_command ;; + stop) stop_command ;; + restart) + stop_command + sleep 1 + start_command + ;; + status) status_command ;; + doctor) doctor_command ;; + runtime-login) runtime_login_command "$@" ;; + logs) logs_command ;; + help|-h|--help) usage ;; + *) + print_error "Unknown command: $command" + usage >&2 + exit 1 + ;; +esac diff --git a/shell/buzznode-panel-status b/shell/buzznode-panel-status new file mode 100755 index 0000000..13ec121 --- /dev/null +++ b/shell/buzznode-panel-status @@ -0,0 +1,13 @@ +#!/bin/bash +# Render the compact Buzznode state shown by the Tint2 panel executor. + +set -euo pipefail + +if ! buzznode configured >/dev/null 2>&1; then + printf '○ Set up Buzznode\n' +elif NO_COLOR=1 buzznode status 2>/dev/null | + grep -Fq 'Agent harness: running'; then + printf '● running\n' +else + printf '● stopped\n' +fi diff --git a/shell/chromium b/shell/chromium new file mode 100755 index 0000000..6fde25f --- /dev/null +++ b/shell/chromium @@ -0,0 +1,29 @@ +#!/bin/bash +# chromium - wrapper that launches Google Chrome with container-safe flags. +# 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 +# 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) +for node in /dev/dri/renderD*; do + if [ -r "$node" ] && [ -w "$node" ]; then + gpu_args=(--ignore-gpu-blocklist --enable-gpu-rasterization) + break + fi +done + +exec /opt/google/chrome/google-chrome \ + --no-sandbox \ + --test-type \ + "${gpu_args[@]}" \ + --disable-dev-shm-usage \ + --no-first-run \ + --no-default-browser-check \ + --disable-infobars \ + --force-dark-mode \ + --enable-features=WebContentsForceDark \ + "$@" diff --git a/shell/welcome b/shell/welcome new file mode 100755 index 0000000..b665421 --- /dev/null +++ b/shell/welcome @@ -0,0 +1,44 @@ +#!/bin/bash +# First-launch guide for Buzznode. + +clear + +Y=$'\e[38;2;215;215;46m' +D=$'\e[38;5;243m' +B=$'\e[1m' +R=$'\e[0m' + +cat <>"$runtime_log" + if [ "${*: -1}" = "--with-api-key" ]; then + IFS= read -r input || true + printf 'stdin-length=%s\n' "${#input}" >>"$runtime_log" + fi +} +export -f mock_runtime + +run_helper() { + CODEX_COMMAND=mock_runtime \ + CLAUDE_COMMAND=mock_runtime \ + GOOSE_COMMAND=mock_runtime \ + bash "$helper" "$@" >/dev/null +} + +run_helper codex device +run_helper codex browser +printf 'test-only-secret\n' | + CODEX_COMMAND=mock_runtime bash "$helper" codex api-key >/dev/null +run_helper codex status + +grep -Fxq 'login --device-auth' "$runtime_log" +grep -Fxq 'login' "$runtime_log" +grep -Fxq 'login --with-api-key' "$runtime_log" +grep -Fxq 'stdin-length=16' "$runtime_log" +grep -Fxq 'login status' "$runtime_log" +if grep -Fq 'test-only-secret' "$runtime_log"; then + echo "Codex API key was passed as a command argument" >&2 + exit 1 +fi + +run_helper claude subscription +run_helper claude console +run_helper claude setup-token +run_helper claude sso +run_helper claude status + +grep -Fxq 'auth login --claudeai' "$runtime_log" +grep -Fxq 'auth login --console' "$runtime_log" +grep -Fxq 'setup-token' "$runtime_log" +grep -Fxq 'auth login --sso' "$runtime_log" +grep -Fxq 'auth status' "$runtime_log" + +printf '\n' | + CODEX_LOGIN_DEFAULT=device CODEX_COMMAND=mock_runtime \ + bash "$helper" codex >/dev/null 2>/dev/null +test "$(tail -n 1 "$runtime_log")" = 'login --device-auth' + +printf '\n' | + CODEX_LOGIN_DEFAULT=browser CODEX_COMMAND=mock_runtime \ + bash "$helper" codex >/dev/null 2>/dev/null +test "$(tail -n 1 "$runtime_log")" = 'login' + +run_helper goose +grep -Fxq 'configure' "$runtime_log" + +if run_helper codex unknown >/dev/null 2>&1; then + echo "Unknown Codex authentication method was accepted" >&2 + exit 1 +fi + +echo "Agent runtime login tests passed." diff --git a/tests/test-buzznode.sh b/tests/test-buzznode.sh new file mode 100755 index 0000000..7c023ed --- /dev/null +++ b/tests/test-buzznode.sh @@ -0,0 +1,200 @@ +#!/bin/bash + +set -euo pipefail + +project_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +temporary_dir="$(mktemp -d)" +trap 'rm -rf "$temporary_dir"' EXIT + +export HOME="$temporary_dir/home" +export BUZZNODE_CONFIG_DIR="$HOME/.config/buzznode" +mkdir -p "$HOME" + +cli="$project_dir/shell/buzznode" +token='token with spaces and $shell characters' +private_key='0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef' +auth_tag='{"kind":"owner delegation","value":"$secret"}' + +"$cli" configure \ + --relay-url 'wss://team.example.com' \ + --private-key "$private_key" \ + --auth-tag "$auth_tag" \ + --api-token "$token" \ + --runtime codex >/dev/null + +test -f "$BUZZNODE_CONFIG_DIR/environment" +test "$(stat -c '%a' "$BUZZNODE_CONFIG_DIR/environment")" = "600" + +# shellcheck disable=SC1090 +source "$BUZZNODE_CONFIG_DIR/environment" +test "$BUZZ_RELAY_URL" = "wss://team.example.com" +test "$BUZZ_PRIVATE_KEY" = "$private_key" +test "$BUZZ_AUTH_TAG" = "$auth_tag" +test "$BUZZ_API_TOKEN" = "$token" +test "$BUZZNODE_RUNTIME" = "codex" +test "$BUZZ_ACP_AGENT_COMMAND" = "codex-acp" +test "$BUZZ_ACP_AGENT_ARGS" = "" +test "$BUZZ_ACP_MCP_COMMAND" = "buzz-dev-mcp" +test "$BUZZ_ACP_RESPOND_TO" = "anyone" + +"$cli" configured +status_output="$("$cli" status)" +grep -q 'Relay: wss://team.example.com' <<<"$status_output" +grep -q 'Agent key: configured' <<<"$status_output" +for secret in "$private_key" "$auth_tag" "$token"; do + if grep -Fq "$secret" <<<"$status_output"; then + echo "status leaked a stored agent credential" >&2 + exit 1 + fi +done + +color_status_output="$(env -u NO_COLOR FORCE_COLOR=1 "$cli" status)" +grep -Fq $'\033[' <<<"$color_status_output" +plain_status_output="$(FORCE_COLOR=1 NO_COLOR=1 "$cli" status)" +if grep -Fq $'\033[' <<<"$plain_status_output"; then + echo "NO_COLOR did not disable Buzznode styling" >&2 + exit 1 +fi + +panel_status="$project_dir/shell/buzznode-panel-status" +mock_bin="$temporary_dir/bin" +mkdir -p "$mock_bin" +ln -s "$cli" "$mock_bin/buzznode" + +unconfigured_panel_output="$( + env -u BUZZ_RELAY_URL -u BUZZ_PRIVATE_KEY \ + PATH="$mock_bin:$PATH" \ + BUZZNODE_CONFIG_DIR="$temporary_dir/unconfigured" \ + "$panel_status" +)" +grep -Fq 'Set up Buzznode' <<<"$unconfigured_panel_output" + +configured_panel_output="$(PATH="$mock_bin:$PATH" "$panel_status")" +grep -Eq '(running|stopped)' <<<"$configured_panel_output" + +normalized_menu="$( + tr '\n\t' ' ' < "$project_dir/openbox/menu.xml" | tr -s ' ' +)" +grep -Fq 'buzznode setup; exec bash' <<<"$normalized_menu" +grep -Fq "buzznode setup; exec bash" "$project_dir/openbox/autostart" +grep -Fq 'tint2 -c /etc/xdg/tint2/tint2rc' \ + "$project_dir/openbox/autostart" +grep -Fq 'kitty --title "Agent Harness Log" -e buzznode logs' \ + <<<"$normalized_menu" +grep -Fq 'panel_items = PTSEC' "$project_dir/tint2/tint2rc" +grep -Fq 'execp_command = buzznode-panel-status' "$project_dir/tint2/tint2rc" +grep -Fq 'buzznode status; exec bash' "$project_dir/tint2/tint2rc" +grep -Fq 'assets/favicon.svg' "$project_dir/kasm/patch.sh" +grep -Fq 'COPY kasm/favicon.svg /usr/share/kasmvnc/www/assets/favicon.svg' \ + "$project_dir/Dockerfile" + +if "$cli" configure --relay-url 'https://not-a-websocket.example.com' \ + --private-key "$private_key" >/dev/null 2>&1; then + echo "configure accepted a non-WebSocket relay URL" >&2 + exit 1 +fi + +if "$cli" configure --relay-url 'wss://team.example.com' \ + --private-key "$private_key" --runtime unknown >/dev/null 2>&1; then + echo "configure accepted an unknown runtime" >&2 + exit 1 +fi + +if "$cli" configure --relay-url 'wss://team.example.com' \ + --private-key "$private_key" --respond-to owner-only >/dev/null 2>&1; then + echo "configure accepted owner-only mode without owner credentials" >&2 + exit 1 +fi + +if "$cli" configure --relay-url 'wss://team.example.com' \ + --private-key "$private_key" --respond-to allowlist >/dev/null 2>&1; then + echo "configure accepted allowlist mode without public keys" >&2 + exit 1 +fi + +allowlist_key='1111111111111111111111111111111111111111111111111111111111111111' +enrollment_json="$( + jq -nc \ + --arg private_key "$private_key" \ + --arg auth_tag "$auth_tag" \ + --arg allowlist_key "$allowlist_key" \ + '{ + version: 1, + name: "Node agent", + relay_url: "wss://enrollment.example.com", + private_key: $private_key, + auth_tag: $auth_tag, + respond_to: "allowlist", + respond_to_allowlist: [$allowlist_key] + }' +)" +enrollment_bundle="buzznode-v1:$( + printf '%s' "$enrollment_json" | base64 --wrap=0 +)" +enrollment_dir="$temporary_dir/enrollment" +printf '%s\n' "$enrollment_bundle" | + BUZZNODE_CONFIG_DIR="$enrollment_dir" "$cli" configure \ + --enrollment-stdin --runtime claude >/dev/null + +# shellcheck disable=SC1090 +source "$enrollment_dir/environment" +test "$BUZZ_RELAY_URL" = "wss://enrollment.example.com" +test "$BUZZ_PRIVATE_KEY" = "$private_key" +test "$BUZZ_AUTH_TAG" = "$auth_tag" +test "$BUZZ_ACP_RESPOND_TO" = "allowlist" +test "$BUZZ_ACP_RESPOND_TO_ALLOWLIST" = "$allowlist_key" +test "$BUZZNODE_RUNTIME" = "claude" + +if printf '%s\n' 'buzznode-v1:not-base64' | + BUZZNODE_CONFIG_DIR="$temporary_dir/invalid-enrollment" \ + "$cli" configure --enrollment-stdin >/dev/null 2>&1; then + echo "configure accepted an invalid enrollment bundle" >&2 + exit 1 +fi + +empty_relay_json="$( + jq -c '.relay_url = ""' <<<"$enrollment_json" +)" +empty_relay_bundle="buzznode-v1:$( + printf '%s' "$empty_relay_json" | base64 --wrap=0 +)" +empty_relay_error="$temporary_dir/empty-relay-error" +if printf '%s\n' "$empty_relay_bundle" | + BUZZNODE_CONFIG_DIR="$temporary_dir/empty-relay-enrollment" \ + "$cli" configure --enrollment-stdin \ + >/dev/null 2>"$empty_relay_error"; then + echo "configure accepted an enrollment bundle without a relay URL" >&2 + exit 1 +fi +grep -q 'Enrollment bundle contains an invalid relay URL' \ + "$empty_relay_error" + +missing_key_dir="$temporary_dir/missing-key" +env -u BUZZ_PRIVATE_KEY \ + BUZZNODE_CONFIG_DIR="$missing_key_dir" \ + BUZZ_RELAY_URL='wss://team.example.com' \ + "$cli" configured >/dev/null 2>&1 && { + echo "configured accepted a node without an agent private key" >&2 + exit 1 + } + +for runtime in claude goose; do + runtime_dir="$temporary_dir/$runtime" + BUZZNODE_CONFIG_DIR="$runtime_dir" "$cli" configure \ + --relay-url 'wss://team.example.com' \ + --private-key "$private_key" \ + --runtime "$runtime" >/dev/null +done + +# shellcheck disable=SC1090 +source "$temporary_dir/claude/environment" +test "$BUZZ_ACP_AGENT_COMMAND" = "claude-agent-acp" +test "$BUZZ_ACP_AGENT_ARGS" = "" +test -z "${BUZZ_ACP_MCP_COMMAND:-}" + +# shellcheck disable=SC1090 +source "$temporary_dir/goose/environment" +test "$BUZZ_ACP_AGENT_COMMAND" = "goose" +test "$BUZZ_ACP_AGENT_ARGS" = "acp" + +echo "Buzznode CLI tests passed." diff --git a/tint2/tint2rc b/tint2/tint2rc new file mode 100644 index 0000000..7b3439a --- /dev/null +++ b/tint2/tint2rc @@ -0,0 +1,231 @@ +#------------------------------------- +# Backgrounds +#------------------------------------- +# Background 1: Panel +rounded = 0 +border_width = 0 +border_sides = TBLR +border_content_tint_weight = 0 +background_content_tint_weight = 0 +background_color = #000000 100 +border_color = #000000 0 +background_color_hover = #000000 100 +border_color_hover = #000000 0 +background_color_pressed = #000000 100 +border_color_pressed = #000000 0 + +# Background 2: Active task +rounded = 0 +border_width = 0 +border_sides = TBLR +border_content_tint_weight = 0 +background_content_tint_weight = 0 +background_color = #1a1a1a 100 +border_color = #1a1a1a 0 +background_color_hover = #222222 100 +border_color_hover = #222222 0 +background_color_pressed = #222222 100 +border_color_pressed = #222222 0 + +# Background 3: Taskbar +rounded = 0 +border_width = 0 +border_sides = TBLR +border_content_tint_weight = 0 +background_content_tint_weight = 0 +background_color = #000000 0 +border_color = #000000 0 +background_color_hover = #000000 0 +border_color_hover = #000000 0 +background_color_pressed = #000000 0 +border_color_pressed = #000000 0 + +# Background 4: Tooltip +rounded = 0 +border_width = 0 +border_sides = TBLR +border_content_tint_weight = 0 +background_content_tint_weight = 0 +background_color = #000000 85 +border_color = #000000 0 +background_color_hover = #000000 85 +border_color_hover = #000000 0 +background_color_pressed = #000000 85 +border_color_pressed = #000000 0 + +# Background 5: Menu button +rounded = 0 +border_width = 0 +border_sides = TBLR +border_content_tint_weight = 0 +background_content_tint_weight = 0 +background_color = #000000 0 +border_color = #000000 0 +background_color_hover = #ffffff 10 +border_color_hover = #000000 0 +background_color_pressed = #ffffff 15 +border_color_pressed = #000000 0 + +# Background 6: Clock / Executor badge +rounded = 0 +border_width = 0 +border_sides = TBLR +border_content_tint_weight = 0 +background_content_tint_weight = 0 +background_color = #000000 0 +border_color = #ffffff 0 +background_color_hover = #ffffff 10 +border_color_hover = #ffffff 0 +background_color_pressed = #ffffff 10 +border_color_pressed = #ffffff 0 + +#------------------------------------- +# Panel +panel_items = PTSEC +panel_size = 100% 36 +panel_margin = 16 8 +panel_padding = 4 4 4 +panel_background_id = 1 +wm_menu = 0 +panel_dock = 0 +panel_position = top center horizontal +panel_layer = top +panel_monitor = all +panel_shrink = 0 +autohide = 0 +autohide_show_timeout = 0.3 +autohide_hide_timeout = 1.5 +autohide_height = 4 +strut_policy = follow_size +panel_window_name = tint2 +disable_transparency = 0 +mouse_effects = 1 +font_shadow = 0 +mouse_hover_icon_asb = 100 0 10 +mouse_pressed_icon_asb = 100 0 0 + +#------------------------------------- +# Taskbar +taskbar_mode = single_desktop +taskbar_hide_if_empty = 0 +taskbar_padding = 0 0 4 +taskbar_background_id = 3 +taskbar_active_background_id = 3 +taskbar_name = 0 +taskbar_hide_inactive_tasks = 0 +taskbar_hide_different_monitor = 0 +taskbar_hide_different_desktop = 0 +taskbar_always_show_all_desktop_tasks = 0 +taskbar_name_padding = 6 2 +taskbar_name_background_id = 6 +taskbar_name_active_background_id = 6 +taskbar_name_font = Noto Sans 9 +taskbar_name_font_color = #a0a0a0 100 +taskbar_name_active_font_color = #ffffff 100 +taskbar_distribute_size = 0 +taskbar_sort_order = none +task_align = left + +#------------------------------------- +# Task +task_text = 1 +task_icon = 0 +task_centered = 1 +urgent_nb_of_blink = 20 +task_maximum_size = 200 30 +task_padding = 8 2 2 +task_font = Noto Sans 9 +task_tooltip = 1 +task_thumbnail = 0 +task_thumbnail_size = 210 +task_font_color = #b0b0b0 100 +task_active_font_color = #ffffff 100 +task_urgent_font_color = #ff4444 100 +task_iconified_font_color = #666666 100 +task_icon_asb = 100 0 0 +task_active_icon_asb = 100 0 0 +task_urgent_icon_asb = 100 0 0 +task_iconified_icon_asb = 80 0 0 +task_background_id = 0 +task_active_background_id = 2 +task_urgent_background_id = 0 +task_iconified_background_id = 0 +mouse_left = toggle_iconify +mouse_middle = none +mouse_right = close +mouse_scroll_up = toggle +mouse_scroll_down = iconify + +#------------------------------------- +# System tray (notification area) +systray_padding = 4 2 3 +systray_background_id = 0 +systray_sort = right2left +systray_icon_size = 18 +systray_icon_asb = 100 0 0 +systray_monitor = 1 +systray_name_filter = + +#------------------------------------- +# Buzznode status +execp = new +execp_command = buzznode-panel-status +execp_interval = 5 +execp_continuous = 0 +execp_has_icon = 0 +execp_tooltip = Click to open Buzznode status +execp_font = Noto Sans 9 +execp_font_color = #cccccc 100 +execp_markup = 1 +execp_background_id = 6 +execp_centered = 1 +execp_padding = 8 4 0 +execp_monitor = all +execp_lclick_command = kitty --title "Buzznode Status" -e bash -lc "buzznode status; exec bash" +execp_mclick_command = +execp_rclick_command = +execp_uwheel_command = +execp_dwheel_command = + +#------------------------------------- +# Clock +time1_format = %a %d %b %H:%M +time2_format = +time1_font = Noto Sans 9 +time1_timezone = +time2_timezone = +clock_font_color = #cccccc 100 +clock_padding = 8 4 +clock_background_id = 6 +clock_tooltip = +clock_tooltip_timezone = +clock_lclick_command = +clock_rclick_command = +clock_mclick_command = +clock_uwheel_command = +clock_dwheel_command = + +#------------------------------------- +# Button (menu launcher) +button = new +button_text = ≡ +button_font = Noto Sans Bold 12 +button_font_color = #ffffff 80 +button_padding = 8 0 +button_background_id = 5 +button_centered = 1 +button_max_icon_size = 0 +button_lclick_command = xdotool key --clearmodifiers super+F1 +button_rclick_command = xdotool key --clearmodifiers super+F1 +button_mclick_command = +button_uwheel_command = +button_dwheel_command = + +#------------------------------------- +# Tooltip +tooltip_show_timeout = 0.5 +tooltip_hide_timeout = 0 +tooltip_padding = 6 4 +tooltip_background_id = 4 +tooltip_font_color = #cccccc 100 +tooltip_font = Noto Sans 9 diff --git a/tools/size-report.sh b/tools/size-report.sh new file mode 100755 index 0000000..3b1cbae --- /dev/null +++ b/tools/size-report.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# +# Report how much of an image is the graphical (desktop) substrate. +# +# The graphical stack is measured as the dependency closure of the desktop +# top-level packages: whatever apt would take out if they were purged. That +# accounts for shared libraries pulled in transitively, which a naive per- +# package sum misses. Packages absent from the image are skipped, so the same +# script reports on Buzzbox and Buzznode. +# +# Usage: tools/size-report.sh [IMAGE] +set -euo pipefail + +IMAGE="${1:-${IMAGE:-pdparchitect/buzznode:local}}" +DOCKER="${DOCKER:-docker}" + +if ! "$DOCKER" image inspect "$IMAGE" >/dev/null 2>&1; then + echo "Image $IMAGE not found. Run 'make build' first." >&2 + exit 1 +fi + +total_bytes="$("$DOCKER" image inspect "$IMAGE" --format '{{.Size}}')" + +echo "Image: $IMAGE" +awk -v b="$total_bytes" 'BEGIN {printf "Total size: %.2f GB (%.0f MiB)\n", b/1e9, b/1048576}' +echo +echo "Largest layers" +echo "--------------" +"$DOCKER" history --no-trunc --format '{{.Size}}\t{{.CreatedBy}}' "$IMAGE" \ + | sort -rh | head -10 \ + | sed -E 's/\|[0-9]+ ([A-Za-z0-9_]+=[^ ]* )+//; s|/bin/(ba)?sh -o pipefail -c ||; + s|/bin/(ba)?sh -c ||; s/#\(nop\) //; s/[[:space:]]+/ /g' \ + | cut -c1-104 +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 +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 +libnss3 libatk1.0-0t64 libatk-bridge2.0-0t64 libcups2t64 libdrm2 +libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 +libpango-1.0-0 libasound2t64 libxshmfence1 +libwebkit2gtk-4.1-0 libgtk-3-0 libgtk-3-0t64 libayatana-appindicator3-1 librsvg2-2 +" + +"$DOCKER" run --rm --interactive --entrypoint bash \ + --env "GRAPHICAL_TOPLEVEL=$GRAPHICAL_TOPLEVEL" \ + --env "TOTAL_BYTES=$total_bytes" \ + "$IMAGE" -s <<'INNER' +set -uo pipefail + +dpkg-query -Wf '${Package}\t${Installed-Size}\n' > /tmp/sizes.txt +dpkg-query -Wf '${db:Status-Abbrev} ${Package}\n' | awk '/^ii/{print $2}' > /tmp/installed.txt + +present="" +for pkg in $GRAPHICAL_TOPLEVEL; do + grep -qx "$pkg" /tmp/installed.txt && present="$present $pkg" +done + +apt-get remove --purge --autoremove --dry-run $present 2>/dev/null \ + | awk '/^(Purg|Remv) /{print $2}' | sort -u > /tmp/graphical.txt + +echo "Graphical package closure" +echo "-------------------------" +awk 'NR==FNR {want[$1]=1; next} + want[$1] { + p=$1; s=$2 + if (p ~ /^google-chrome/) c="Chrome 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. + else if (p == "buzz") c="Buzz .deb (GUI binary + headless tools)" + else if (p == "kasmvncserver" || p ~ /perl/) c="KasmVNC and its perl deps" + else if (p ~ /^(fonts-|xfonts-|.*-icon-theme|ubuntu-mono|yudit|poppler-data)/) c="Fonts and icon themes" + else if (p ~ /llvm|mesa|^libgl|^libegl|^libvulkan|drm|gallium/) c="Mesa and LLVM software GL" + else if (p ~ /^(libgs10|libspectre|ghostscript)/) c="Ghostscript (via openbox/tint2 imlib2)" + else if (p ~ /^(cpp|gcc)/) c="cpp/gcc (via x11-xserver-utils)" + else if (p ~ /^(systemd|udev|libsystemd|dbus-user-session|libpam-systemd)/) c="systemd/udev/dbus session" + else if (p ~ /^(xserver|xorg|x11|xauth|xinit|xkb|libx|libxcb|xterm|xdotool|wmctrl|xclip|scrot|libxcvt)/) c="X11 server and utilities" + else if (p ~ /^(openbox|obconf|tint2|picom|feh|libimlib)/) c="Window manager, panel, compositor" + else if (p ~ /^kitty/) c="kitty terminal" + else if (p ~ /gtk|gdk|pango|cairo|atk|adwaita|rsvg|gstreamer|glib/) c="GTK, Pango, GStreamer" + else c="Other shared libraries" + kb[c] += s; n[c]++; total += s; count++ + } + END { + for (k in kb) printf "%9.1f MiB %4d pkgs %s\n", kb[k]/1024, n[k], k + printf "%9.1f MiB %4d pkgs TOTAL\n", total/1024, count + printf "%d\n", total > "/tmp/total_kb" + }' /tmp/graphical.txt /tmp/sizes.txt | sort -rn + +echo +echo "Share of image" +echo "--------------" +graphical_kb="$(cat /tmp/total_kb)" +awk -v g="$graphical_kb" -v t="$TOTAL_BYTES" 'BEGIN { + gb = g * 1024 + printf "Graphical stack: %.0f MiB of %.0f MiB (%.1f%%)\n", gb/1048576, t/1048576, 100*gb/t + printf "Everything else: %.0f MiB (%.1f%%)\n", (t-gb)/1048576, 100*(t-gb)/t +}' + +echo +echo "Non-package graphical assets" +echo "----------------------------" +du -sh --total /usr/share/kasmvnc /usr/local/bin/cortile /usr/share/themes \ + /usr/share/backgrounds /opt/browser 2>/dev/null | sed 's/^/ /' +INNER diff --git a/wallpaper/buzz-grid.svg b/wallpaper/buzz-grid.svg new file mode 100644 index 0000000..cd3be41 --- /dev/null +++ b/wallpaper/buzz-grid.svg @@ -0,0 +1,4 @@ + + + +