From a9c4b41f15efb6345f3723e81d59a53972608272 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 13:22:16 -0500 Subject: [PATCH 1/3] feat(paseo): package the headless assistant and record turns on Buzz Signed-off-by: Tee8z --- .github/workflows/voltage-agent-images.yml | 31 +- voltage/images/README.md | 7 +- voltage/images/paseo-daemon/Dockerfile | 82 +++++ .../paseo-daemon/Dockerfile.dockerignore | 91 ++++++ voltage/images/paseo-daemon/README.md | 159 +++++++++ voltage/images/paseo-daemon/assistant.md | 58 ++++ voltage/images/paseo-daemon/entrypoint.sh | 308 ++++++++++++++++++ voltage/images/paseo-daemon/package-lock.json | 137 ++++++++ voltage/images/paseo-daemon/package.json | 8 + .../paseo-daemon/recorder/index.server.ts | 1 + .../paseo-daemon/recorder/paseo-plugin.json | 1 + .../paseo-daemon/recorder/server/recorder.mjs | 170 ++++++++++ voltage/images/paseo-daemon/test-image.sh | 259 +++++++++++++++ voltage/images/paseo-daemon/test-recorder.mjs | 67 ++++ 14 files changed, 1373 insertions(+), 6 deletions(-) create mode 100644 voltage/images/paseo-daemon/Dockerfile create mode 100644 voltage/images/paseo-daemon/Dockerfile.dockerignore create mode 100644 voltage/images/paseo-daemon/README.md create mode 100644 voltage/images/paseo-daemon/assistant.md create mode 100644 voltage/images/paseo-daemon/entrypoint.sh create mode 100644 voltage/images/paseo-daemon/package-lock.json create mode 100644 voltage/images/paseo-daemon/package.json create mode 100644 voltage/images/paseo-daemon/recorder/index.server.ts create mode 100644 voltage/images/paseo-daemon/recorder/paseo-plugin.json create mode 100644 voltage/images/paseo-daemon/recorder/server/recorder.mjs create mode 100644 voltage/images/paseo-daemon/test-image.sh create mode 100644 voltage/images/paseo-daemon/test-recorder.mjs diff --git a/.github/workflows/voltage-agent-images.yml b/.github/workflows/voltage-agent-images.yml index c482d3e2c12..028a729e457 100644 --- a/.github/workflows/voltage-agent-images.yml +++ b/.github/workflows/voltage-agent-images.yml @@ -9,9 +9,11 @@ name: Voltage images # buzz-agent voltage/images/buzz-agent/Dockerfile → voltage-buzz-agent-staging # buzz-sandbox-reaper voltage/images/buzz-sandbox-reaper/Dockerfile → voltage-buzz-sandbox-reaper-staging # buzz-relay the upstream root Dockerfile (target runtime) → buzz-relay +# paseo-daemon voltage/images/paseo-daemon/Dockerfile → voltage-paseo-daemon-staging # # The changes job selects only the images whose inputs changed. Manual runs -# (workflow_dispatch) build all three, or only buzz-agent with agent_only. +# (workflow_dispatch) build all of them, or only buzz-agent with agent_only. +# Paseo publishes to staging ECR after provisioning in voltage-terraform #1029. # # Trust model: this repository and its logs are public. Pull requests run # without AWS access. Only the publish and merge jobs request an OIDC token, @@ -131,6 +133,15 @@ jobs: - 'examples/**' - 'voltage/images/buzz-sandbox-reaper/**' - '.github/workflows/voltage-agent-images.yml' + paseo: + - '.cargo/**' + - 'Cargo.toml' + - 'Cargo.lock' + - 'rust-toolchain.toml' + - 'crates/**' + - 'examples/**' + - 'voltage/images/paseo-daemon/**' + - '.github/workflows/voltage-agent-images.yml' relay: - 'Dockerfile' - '.dockerignore' @@ -157,16 +168,19 @@ jobs: AGENT: ${{ steps.filter.outputs.agent }} REAPER: ${{ steps.filter.outputs.reaper }} RELAY: ${{ steps.filter.outputs.relay }} + PASEO: ${{ steps.filter.outputs.paseo }} run: | set -euo pipefail selected=() if [ "$ALL" = true ] || [ "$AGENT_ONLY" = true ] || [ "$AGENT" = true ]; then selected+=(buzz-agent); fi if [ "$ALL" = true ] || [ "$REAPER" = true ]; then selected+=(buzz-sandbox-reaper); fi if [ "$ALL" = true ] || [ "$RELAY" = true ]; then selected+=(buzz-relay); fi + if [ "$ALL" = true ] || [ "$PASEO" = true ]; then selected+=(paseo-daemon); fi images='{ "buzz-agent": {"file": "voltage/images/buzz-agent/Dockerfile", "target": "", "repository": "voltage-buzz-agent-staging"}, "buzz-sandbox-reaper": {"file": "voltage/images/buzz-sandbox-reaper/Dockerfile", "target": "", "repository": "voltage-buzz-sandbox-reaper-staging"}, - "buzz-relay": {"file": "Dockerfile", "target": "runtime", "repository": "buzz-relay"} + "buzz-relay": {"file": "Dockerfile", "target": "runtime", "repository": "buzz-relay"}, + "paseo-daemon": {"file": "voltage/images/paseo-daemon/Dockerfile", "target": "", "repository": "voltage-paseo-daemon-staging"} }' platforms='[{"arch": "amd64", "runner": "ubuntu-latest"}, {"arch": "arm64", "runner": "ubuntu-24.04-arm"}]' build=$(jq -cn --argjson images "$images" --argjson platforms "$platforms" \ @@ -202,13 +216,18 @@ jobs: with: dockerfile: voltage/images/buzz-sandbox-reaper/Dockerfile failure-threshold: error + - name: Hadolint paseo-daemon + uses: hadolint/hadolint-action@06be81baf89a55ffd0e24b8f04a4185738dd3387 # v3.5.0 + with: + dockerfile: voltage/images/paseo-daemon/Dockerfile + failure-threshold: error # --check resolves every COPY --from source, so the agent image needs its # engineering-skills context; the other images ignore it. - name: BuildKit checks run: | set -euo pipefail sh voltage/images/buzz-agent/select-skills.sh --none "$RUNNER_TEMP/engineering-skills" - for file in voltage/images/buzz-agent/Dockerfile voltage/images/buzz-sandbox-reaper/Dockerfile Dockerfile; do + for file in voltage/images/buzz-agent/Dockerfile voltage/images/buzz-sandbox-reaper/Dockerfile voltage/images/paseo-daemon/Dockerfile Dockerfile; do echo "==> $file" docker buildx build --check --build-context engineering-skills="$RUNNER_TEMP/engineering-skills" -f "$file" . done @@ -264,6 +283,9 @@ jobs: - name: Relay smoke test if: matrix.image == 'buzz-relay' run: bash voltage/images/test-relay-image.sh "$IMAGE" + - name: Paseo daemon contract test + if: matrix.image == 'paseo-daemon' + run: bash voltage/images/paseo-daemon/test-image.sh "$IMAGE" - name: Image contract tests if: matrix.image == 'buzz-agent' run: | @@ -417,6 +439,9 @@ jobs: - name: Relay smoke test if: matrix.image == 'buzz-relay' run: bash voltage/images/test-relay-image.sh "$IMAGE" + - name: Paseo daemon contract test + if: matrix.image == 'paseo-daemon' + run: bash voltage/images/paseo-daemon/test-image.sh "$IMAGE" - name: Image contract test if: matrix.image == 'buzz-agent' env: diff --git a/voltage/images/README.md b/voltage/images/README.md index a996b3d29fa..c1f06291dfe 100644 --- a/voltage/images/README.md +++ b/voltage/images/README.md @@ -11,11 +11,12 @@ The same workflow also builds the Buzz relay image from the upstream root `Docke | [buzz-agent](buzz-agent/README.md) | Remote developer agent Sandbox: the `sprig` harness, Codex and Claude ACP adapters, and developer tools. It also runs as the `buzz-agent-gate` DaemonSet. | `voltage-buzz-agent-staging` | | [buzz-sandbox-reaper](buzz-sandbox-reaper/Dockerfile) | In-cluster reaper for ended and orphaned agent Sandboxes (`buzz-sandbox-reaper reconcile`) | `voltage-buzz-sandbox-reaper-staging` | | [buzz-relay](#the-relay-image) | Buzz relay, admin, and pair relay binaries with the web bundles, from the root `Dockerfile` | `buzz-relay` | +| [paseo-daemon](paseo-daemon/README.md) | Paseo daemon that runs a developer's personal assistant in front of Buzz remote agents | `voltage-paseo-daemon-staging` | Each image compiles the Buzz crates of the commit it is built from. The build context is the repository root. -For the two agent images, BuildKit reads the `Dockerfile.dockerignore` next to each Dockerfile instead of the root `.dockerignore`. -These files keep only the Cargo workspace and, for `buzz-agent`, the files that its Dockerfile copies from `voltage/images/buzz-agent`. +For the agent images and `paseo-daemon`, BuildKit reads the `Dockerfile.dockerignore` next to each Dockerfile instead of the root `.dockerignore`. +These files keep only the Cargo workspace and the files that each Dockerfile copies from its own directory. ## Publishing @@ -23,7 +24,7 @@ These files keep only the Cargo workspace and, for `buzz-agent`, the files that Its first job selects only the images whose inputs changed; manual runs build all of them. - Pull requests: hadolint and BuildKit checks, then a build of each selected image for `linux/amd64` and `linux/arm64` on native runners, without a push. - The workflow tests the agent image on both platforms, runs the reaper binary, and smoke-tests the relay image with [`test-relay-image.sh`](test-relay-image.sh). + The workflow tests the agent image on both platforms, runs the reaper binary, smoke-tests the relay image with [`test-relay-image.sh`](test-relay-image.sh), and runs [`paseo-daemon/test-image.sh`](paseo-daemon/test-image.sh). These jobs have no AWS access. - `main`: each platform builds on its native runner and is pushed by digest. A merge job then creates one manifest list per image, tagged with the full commit SHA. diff --git a/voltage/images/paseo-daemon/Dockerfile b/voltage/images/paseo-daemon/Dockerfile new file mode 100644 index 00000000000..ddbe15ac046 --- /dev/null +++ b/voltage/images/paseo-daemon/Dockerfile @@ -0,0 +1,82 @@ +# syntax=docker/dockerfile:1.7 +# Headless Paseo daemon (paseo.sh) that runs one developer's personal +# assistant (PA) in front of their Buzz agents. The daemon is the unmodified +# upstream image. This image adds the `buzz` CLI, the PA agent CLIs, the PA +# instructions, and a wrapper that writes Paseo's config from the environment. +# See README.md next to this file. +# Build from the repository root; Dockerfile.dockerignore next to this file +# selects the context. +FROM rust:1.95-alpine3.22@sha256:064dfc925d68d1a63f4fd2871bd7dc6e6ea56692989a487185855d62885d90aa AS builder +RUN apk add --no-cache build-base cmake git musl-dev perl pkgconf +WORKDIR /build +ENV CARGO_INCREMENTAL=0 CARGO_BUILD_JOBS=2 +# Only the Cargo workspace: a change to the wrapper or the docs does not +# rebuild the CLI. +COPY .cargo .cargo +COPY Cargo.toml Cargo.lock rust-toolchain.toml ./ +COPY crates crates +COPY examples examples +COPY scripts scripts +COPY test-fixtures test-fixtures +# The musl target links statically, so the binary runs on the Debian base. +# The target directory is removed in the same layer to keep the cache small. +RUN cargo build --locked --release -p buzz-cli --bin buzz \ + && install -D -m 0755 -s target/release/buzz /out/buzz \ + && rm -rf target + +# ghcr.io/getpaseo/paseo:0.15.3 (linux/amd64 and linux/arm64) +FROM ghcr.io/getpaseo/paseo@sha256:57048c43a2c2988434273c7f8a4b25aa585bca6c1720493ae52e2fa91497b99a +LABEL maintainer="Voltage " +USER root +SHELL ["/bin/bash", "-o", "pipefail", "-c"] +# Codex is the PA fallback when only CODEX_ACCESS_TOKEN is present. The +# version is pinned by package-lock.json, the same as in the buzz-agent image. +WORKDIR /opt/paseo-buzz/runtimes +COPY voltage/images/paseo-daemon/package.json voltage/images/paseo-daemon/package-lock.json ./ +RUN npm ci --omit=dev --no-audit --no-fund \ + && npm cache clean --force \ + && rm -rf /root/.npm \ + && ln -s /opt/paseo-buzz/runtimes/node_modules/.bin/codex /usr/local/bin/codex \ + && HOME=/tmp codex --version | grep -qx 'codex-cli 0.159.3' +# The Claude Code CLI is the binary that the daemon's Claude Agent SDK bundles +# for this platform (Claude Code 2.1.246 with SDK 0.3.246 in Paseo 0.15.3), so +# the base digest pins it and it matches the SDK. Paseo's claude provider +# runs `claude` from PATH. +RUN shopt -s nullglob \ + && binaries=(/usr/local/lib/node_modules/@getpaseo/server/node_modules/@anthropic-ai/claude-agent-sdk-linux-*/claude) \ + && if [ "${#binaries[@]}" -ne 1 ]; then echo "expected one bundled Claude Code binary, found: ${binaries[*]}" >&2; exit 1; fi \ + && ln -s "${binaries[0]}" /usr/local/bin/claude \ + && HOME=/tmp claude --version | grep -qx '2.1.246 (Claude Code)' +# Host tools off (BUZZ_PASEO_HOST_TOOLS=off): the wrapper links the daemon's +# terminal worker directory to the empty, root-owned no-terminal-worker +# directory, and puts host-tools-off first on PATH, where `git` always fails. +RUN install -d -o root -g root -m 0555 /opt/paseo-buzz/no-terminal-worker \ + && install -d -o root -g root -m 0755 /opt/paseo-buzz/host-tools-off /etc/paseo-buzz \ + && printf '%s\n' '#!/bin/sh' \ + 'echo "git is not available: host tools are off (BUZZ_PASEO_HOST_TOOLS=off)" >&2' \ + 'exit 1' >/opt/paseo-buzz/host-tools-off/git \ + && chmod 0755 /opt/paseo-buzz/host-tools-off/git +COPY --from=builder /out/buzz /usr/local/bin/buzz +COPY voltage/images/paseo-daemon/recorder /opt/paseo-buzz/recorder +COPY --chmod=0644 voltage/images/paseo-daemon/assistant.md /etc/paseo-buzz/assistant.md +COPY --chmod=0755 voltage/images/paseo-daemon/entrypoint.sh /usr/local/bin/paseo-buzz-entrypoint +# Relay-only and TLS-only. The listener is loopback, for the in-container +# CLI, the agent MCP server, and the health check. Clients, including the web +# app at app.paseo.sh, reach the daemon only through the relay. The daemon's +# own web UI and the speech models are off (dictation downloads about 631 MB +# at first start). The wrapper refuses to start without TLS. Claude Code does +# not update itself or send telemetry. +ENV PASEO_LISTEN=127.0.0.1:6767 \ + PASEO_RELAY_ENABLED=true \ + PASEO_RELAY_ENDPOINT=buzz.voltage.cloud:443 \ + PASEO_RELAY_USE_TLS=true \ + PASEO_RELAY_PUBLIC_USE_TLS=true \ + PASEO_WEB_UI_ENABLED=false \ + PASEO_DICTATION_ENABLED=false \ + BUZZ_RELAY_URL=wss://buzz.voltage.cloud \ + BUZZ_PASEO_HOST_TOOLS=off \ + DISABLE_AUTOUPDATER=1 \ + CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 +USER 1000:1000 +WORKDIR /home/paseo +ENTRYPOINT ["/usr/local/bin/paseo-buzz-entrypoint"] diff --git a/voltage/images/paseo-daemon/Dockerfile.dockerignore b/voltage/images/paseo-daemon/Dockerfile.dockerignore new file mode 100644 index 00000000000..13fe8bd255b --- /dev/null +++ b/voltage/images/paseo-daemon/Dockerfile.dockerignore @@ -0,0 +1,91 @@ +# Build outputs +target/ +**/target/ +desktop/src-tauri/target/ + +# Native non-relay UI builds (not part of the public image) +desktop/ +mobile/ + +# Node artifacts (built inside the image; host outputs would invalidate cache) +node_modules/ +**/node_modules/ +web/dist/ + +# VCS, IDE, scratch +.git/ +.github/ +.gitignore +.gitattributes +.vscode/ +.idea/ +.scratch/ + +# Local environment / secrets — never want these in the build context +.env +.env.* +!.env.example +*.pem +*.key +secrets/ + +# OS junk +.DS_Store +Thumbs.db + +# CI artifacts and tooling that the image doesn't need +.cache/ +coverage/ +dist/ +playwright-report/ +test-results/ +bin/ +sidecars/ + +# Docs the image doesn't ship. examples/ stays — examples/countdown-bot is +# a Cargo workspace member, so cargo-chef needs its manifest. Markdown files +# inside crates (Cargo.toml `readme = ...`) likewise need to be present to +# avoid manifest warnings; the savings from excluding them are negligible. +docs/ + +# Old Dockerfile noise (this Dockerfile is THE source of truth now) +docker-compose.yml +docker-compose.*.yml +prometheus.yml + +# --- Voltage agent images --------------------------------------------------- +# The lines above copy the root .dockerignore. The lines below also exclude +# what `cargo build --locked` does not read. Keep the workspace manifest and +# lockfile, .cargo/, rust-toolchain.toml, every root workspace member (crates/, +# examples/), and scripts/ and test-fixtures/, which crates include at +# compile time. +web/ +apps/ +admin-web/ +benchmarks/ +deploy/ +migrations/ +perf/ +PLANS/ +patches/ +schema/ +script/ +.agents/ +.claude/ +.codex/ +.config/ +.goose/ +.hermit/ +.intersect/ +.release/ +**/__pycache__/ +voltage/ +# This image also packages the wrapper, the PA instructions, and the PA +# runtime pins. The other files in voltage/images/paseo-daemon (tests, docs) +# stay out of the context. +!voltage/images/paseo-daemon/entrypoint.sh +!voltage/images/paseo-daemon/assistant.md +!voltage/images/paseo-daemon/package.json +!voltage/images/paseo-daemon/package-lock.json + +!voltage/images/paseo-daemon/recorder/** diff --git a/voltage/images/paseo-daemon/README.md b/voltage/images/paseo-daemon/README.md new file mode 100644 index 00000000000..3caa9dd129a --- /dev/null +++ b/voltage/images/paseo-daemon/README.md @@ -0,0 +1,159 @@ +# Paseo daemon image + +Run one headless [Paseo](https://paseo.sh) daemon per developer as a personal assistant for Buzz remote agents. +The unmodified Paseo 0.15.3 daemon stays compatible with its desktop, mobile, and web clients. +The image adds the Buzz CLI, pinned native assistant runtimes, a configuration wrapper, and a turn-recording plugin. + +The assistant delegates coding and system access to Buzz agents. +Each Buzz thread gets an isolated sandbox, which provides workspace isolation without worktrees in the daemon. +The assistant manages automation through `buzz workflows`, including approvals. +It has no GitHub, cloud, Kubernetes, or sandbox credentials. + +```mermaid +flowchart LR + Client[Paseo desktop, mobile, web] <-->|WSS, encrypted frames| Relay[Paseo relay at /ws] + Relay <--> Daemon[Personal daemon] + Daemon <-->|WSS, signed events| Buzz[Buzz Nostr relay] + Buzz <--> Sandbox[Remote agent sandbox] +``` + +The relay is the only channel into a sandbox. +This image adds no tunnel or substrate connection. +The Paseo relay runs beside the Buzz relay; ingress routes `/ws` to that separate service. + +## Identity and providers + +The manager owns `paseo-daemon-` in namespace `buzz-paseo`. +That Kubernetes Secret is the only store for pairing credentials and the gateway private key. +The shared sandbox profile contains neither. +The daemon has no IAM role, Secrets Manager access, or Kubernetes token. + +The owner grants the gateway key with `buzz gateway grant` or `buzz onboard`. +The gateway also needs ordinary relay and channel membership. +An operator admits it with `buzz-admin add-member --pubkey "$GATEWAY_PUBKEY" --role member`. +A channel administrator runs `buzz channels add-member --channel "$CHANNEL_ID" --pubkey "$GATEWAY_PUBKEY"`. +Include the recording channel when recording is enabled. +A delegation grants no access to the owner's DMs, profile, or administration privileges. + +Each `BUZZ_PASEO_AGENTS` entry adds an ACP provider: + +```json +[{"id":"brokkr","label":"Brokkr","agent":"<64 hex characters>","channel":""}] +``` + +It runs `buzz acp-bridge --agent --channel --owner `. +Model and pairing secrets are removed from its environment. +Claude Code 2.1.246 is the native assistant when `CLAUDE_CODE_OAUTH_TOKEN` exists. +Otherwise, Codex 0.159.3 is selected when `CODEX_ACCESS_TOKEN` exists. +The pinned upstream digest supplies Claude Code and SDK 0.3.246; `package-lock.json` pins Codex. +With no model token, the daemon starts without an assistant. +With no remote-agent list, it starts without ACP providers. +Malformed present values refuse startup with exit code 64. + +## Environment + +| Variable | Contract | +| --- | --- | +| `PASEO_PASSWORD` | Required credential, at least 16 characters. | +| `PASEO_SERVER_ID` | Required stable `srv_` identifier. | +| `PASEO_DAEMON_KEYPAIR` | Required matching Curve25519 `{v:2,publicKeyB64,secretKeyB64}`. Written mode 0600, then removed from the environment. | +| `BUZZ_PRIVATE_KEY` | Required gateway signing key. Never use a sandbox or owner's key in deployment. | +| `BUZZ_PASEO_OWNER` | Owner public key, required when remote providers exist. Terraform supplies it from the developer map. | +| `BUZZ_PASEO_AGENTS` | Optional JSON array above, maximum 64 providers. Missing or `[]` starts without remote providers. | +| `CLAUDE_CODE_OAUTH_TOKEN` | Optional Claude credential; takes precedence over Codex. | +| `CODEX_ACCESS_TOKEN` | Optional `at-...` Codex access token. | +| `BUZZ_RELAY_URL` | Default `wss://buzz.voltage.cloud`; only `wss://` is accepted. | +| `PASEO_RELAY_ENDPOINT` | Default `buzz.voltage.cloud:443`, without scheme or path. | +| `PASEO_RELAY_PUBLIC_ENDPOINT` | Defaults to the relay endpoint; included in pairing offers. | +| `PASEO_RELAY_ENABLED` | Must be `true`. | +| `PASEO_RELAY_USE_TLS`, `PASEO_RELAY_PUBLIC_USE_TLS` | Both must be `true`. | +| `BUZZ_PASEO_HOST_TOOLS` | `off` by default; optional `on` exposes the sandbox terminal profile and snapshots directory. | +| `BUZZ_PASEO_RECORD_CHANNEL` | Optional Buzz channel UUID for native assistant turn records. Missing disables recording with one startup message. | +| `BUZZ_MANAGER_PUBKEY`, `BUZZ_MANAGER_CHANNEL` | Optional manager agent identity and channel; set both together. | +| `PASEO_LISTEN` | Default `127.0.0.1:6767` for local CLI and health checks. | +| `HOME`, `PASEO_HOME`, `CODEX_HOME` | Defaults `/home/paseo`, `$HOME/.paseo`, `$HOME/.codex`. | +| `PASEO_WEB_UI_ENABLED`, `PASEO_DICTATION_ENABLED` | Defaults `false`; generated configuration disables web UI, dictation, and voice. | +| `DISABLE_AUTOUPDATER`, `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` | Defaults `1`. | + +## Recording and recovery + +The `buzz-recorder` directory plugin registers `agent.turn.after` and `agent.open.before` through `@getpaseo/plugin/server`. +It fetches the canonical timeline through `context.paseo.agents.ref(id).timeline.refetch`. +Only native `claude` and `codex` turns are recorded; remote providers already write to Buzz. + +Each record contains the prompt, final answer, tool names, and statuses. +Raw tool outputs and reasoning are excluded. +Known environment credentials and conservative credential patterns cause the whole affected field to be omitted. +This filter reduces accidental disclosure; it cannot classify every possible secret. +Use an appropriate membership-gated recording channel. + +Retry intents are written before timeline reads or publication. +The plugin removes a journal entry only after `buzz` returns `accepted: true`. +Retries back off to 60 seconds; publisher process groups have a 15-second deadline and bounded output. +The spool holds at most 256 records and eight MiB under `$PASEO_HOME/buzz-recording`. +A full spool reports an error instead of claiming the turn was recorded. + +Records ready for publication replay at plugin startup. +Pending timeline reads resume when an agent opens or finishes a turn, providing the plugin API context. +Timeline fetches stop at 1,000 entries; selected entries must match the captured turn ID. +A record carries a stable ID, but ambiguous network acknowledgements can produce duplicates. +The spool survives container restarts while the home `emptyDir` survives. +Pod replacement loses that local state; relay-accepted records remain durable. + +## Host tools and clients + +With host tools off, the wrapper blocks the terminal worker and shadows `git` with a refusing command. +The assistant cannot read or edit project files through its standard tools. +Other shell commands require developer approval; `buzz` commands are allowed. +These settings reduce accidental access; paired clients retain owner control over daemon settings. + +With `BUZZ_PASEO_HOST_TOOLS=on`, `daemon.terminalProfiles` configures `buzz sandbox attach`. +The wrapper creates `/home/paseo/snapshots` for read-only mirrors pulled through `buzz sandbox snapshot pull`. +The sandbox attach PR is forthcoming; these commands are unavailable in this image until it merges and the CLI is rebuilt. +Paseo's terminal profile schema accepts `command` and `args`; `terminal/terminal.js` passes them directly to the PTY. +Profiles select commands; they do not restrict other terminal commands or jail the file explorer. +The forthcoming attach CLI selects targets interactively from `BUZZ_PASEO_AGENTS`; the wrapper preserves that environment variable. +No Paseo fork is included. + +Pairing links open `https://app.paseo.sh` even with the daemon's local web UI disabled. +Upstream `pairing-offer.js` uses `appBaseUrl` independently of `features.webUi`. +Desktop, mobile, and web clients use the relay endpoint embedded in the offer. + +## Build and test + +Run from the repository root: + +```bash +docker build -f voltage/images/paseo-daemon/Dockerfile -t paseo-daemon:local . +bash voltage/images/paseo-daemon/test-image.sh paseo-daemon:local +``` + +The contract test runs without network access, as UID 1000, with a read-only filesystem and dropped capabilities. +It checks pairing identity, TLS refusal, provider selection, optional credentials, host defaults, plugin loading, redaction, retries, and filesystem writes. +It prints idle memory use. +The recorder test also runs independently: `node voltage/images/paseo-daemon/test-recorder.mjs`. + +## Local trial and staging rollout + +For a local trial, supply the required environment values and your own Buzz key through a local environment file. +Set `PASEO_RELAY_ENDPOINT=relay.paseo.sh:443` to use the public Paseo relay. +Keep both TLS flags enabled. + +```bash +docker run -d --name paseo-daemon --env-file /path/to/local-paseo.env \ + --read-only --tmpfs /home/paseo:uid=1000,gid=1000,mode=0700 --tmpfs /tmp \ + --cap-drop ALL --security-opt no-new-privileges --user 1000:1000 paseo-daemon:local +docker exec paseo-daemon paseo daemon pair +``` + +For staging, apply the ECR and GitHub push-policy changes in voltage-terraform #1029 before merging this image. +The images workflow builds native amd64 and arm64 images, runs contract tests, and publishes to `voltage-paseo-daemon-staging` on main. +Its independent matrix lanes keep a daemon-image failure from cancelling other image builds. +The job summary contains the manifest digest. + +Set `paseo_daemon_image` to that digest, then apply the daemon resources. +Use requests 100m CPU / 384Mi memory and limits one CPU / one GiB memory. +Mount writable home and `/tmp` emptyDirs; leave the root filesystem read-only. +Enroll with `buzz onboard`, grant the gateway, admit it to required channels, and pair a Paseo client. +An existing user's Secret update requires a Deployment restart because environment variables are read at process start. +The manager cannot restart Deployments. diff --git a/voltage/images/paseo-daemon/assistant.md b/voltage/images/paseo-daemon/assistant.md new file mode 100644 index 00000000000..5d13b82250b --- /dev/null +++ b/voltage/images/paseo-daemon/assistant.md @@ -0,0 +1,58 @@ +# Your role + +You are the personal assistant of one developer. The developer talks to you from the Paseo desktop or mobile app. + +You do not have access to systems. This container has no repositories, no GitHub access, no cloud or Kubernetes credentials, and no project files. Do not try to clone, build, deploy, or inspect systems yourself. + +You do the work through Buzz agents. Each Buzz agent works in its own sandbox. All work goes through the Buzz relay at {{BUZZ_RELAY_URL}}, so the relay records each request and each result. + +# How to do work + +1. For a small question that needs no system access, answer it yourself. +2. For a large task (a feature, an investigation, a change across repositories), give it to the developer's manager agent. The manager plans the work and assigns it to other agents. +3. For a small, specific task, start a Buzz remote session yourself (see "Buzz remote sessions"). +4. Tell the developer what you started and where they can follow it. Then read the results and report them. + +Before you start work that changes systems, confirm the scope with the developer. + +# The manager agent + +{{MANAGER}} + +# Buzz remote sessions + +{{REMOTE_AGENTS}} + +Use the Paseo tools to start and follow a session: + +- `list_providers` and `list_models` show the Buzz agents and their models. +- `create_agent` with `provider` set to `/` starts a session. The initial prompt goes to the Buzz agent in its channel. +- `get_agent_status` and `get_agent_activity` show the progress. Paseo notifies you when the session finishes. +- `send_agent_prompt` sends a follow-up. + +# The buzz CLI + +Use only the `buzz` command in the shell. Its identity and relay come from the environment. Put `--format` before the subcommand. + +- Send a message and mention an agent: `buzz messages send --channel --mention --content -` (the text comes from standard input). +- Reply in a thread: add `--reply-to `. +- Read a thread: `buzz --format compact messages thread --channel --event `, or `--link ''`. +- Read recent messages: `buzz --format compact messages get --channel --limit 20`. +- Find messages: `buzz --format compact messages search --query `. +- See all commands: `buzz --help`. + +Each write returns an `event_id`. Keep it, so you can read the thread later and give the developer a link. + +# Rules + +- Never print, send, or store secrets. Do not read environment variables or files to find them. +- Do not run commands other than `buzz`. The developer must approve each other command. +- If a Buzz agent asks for access or credentials, tell the developer. Do not supply them yourself. + +# Workflows and gateway access + +Manage automations with `buzz workflows list/get/create/update/delete/trigger/runs/approve`. Use Buzz workflows; do not create daemon-local schedules. + +If Buzz rejects your requests, ask the owner to grant this daemon gateway key with `buzz gateway grant` or `buzz onboard`. Never request the owner's private key. + +Each Buzz thread owns an isolated sandbox, which provides workspace isolation. You do not need local worktrees. diff --git a/voltage/images/paseo-daemon/entrypoint.sh b/voltage/images/paseo-daemon/entrypoint.sh new file mode 100644 index 00000000000..949abedadfe --- /dev/null +++ b/voltage/images/paseo-daemon/entrypoint.sh @@ -0,0 +1,308 @@ +#!/bin/sh +# Writes the Paseo daemon's identity, config, and personal-assistant (PA) +# instructions from the environment, then starts the unmodified upstream +# entrypoint. Optional keys can be missing; a present key that is malformed +# refuses startup with exit code 64. Never prints secret values. +set -eu + +fail() { + printf 'paseo-buzz-entrypoint: %s\n' "$*" >&2 + exit 64 +} + +for name in PASEO_PASSWORD PASEO_SERVER_ID PASEO_DAEMON_KEYPAIR PASEO_RELAY_ENDPOINT \ + BUZZ_RELAY_URL BUZZ_PRIVATE_KEY; do + eval "value=\${$name:-}" + [ -n "$value" ] || fail "$name is required" +done +unset value + +# Clients reach the daemon only through the relay, and only over TLS. The +# daemon turns TLS on by itself only for relay.paseo.sh:443, so require it. +[ "${PASEO_RELAY_ENABLED:-}" = true ] || fail "PASEO_RELAY_ENABLED must be true" +[ "${PASEO_RELAY_USE_TLS:-}" = true ] || fail "PASEO_RELAY_USE_TLS must be true" +[ "${PASEO_RELAY_PUBLIC_USE_TLS:-}" = true ] || fail "PASEO_RELAY_PUBLIC_USE_TLS must be true" +case "$BUZZ_RELAY_URL" in + wss://?*) ;; + *) fail "BUZZ_RELAY_URL must start with wss://" ;; +esac +case "${BUZZ_PASEO_HOST_TOOLS:=off}" in + off | on) ;; + *) fail "BUZZ_PASEO_HOST_TOOLS must be off or on" ;; +esac + +# An empty public endpoint means the default: PASEO_RELAY_ENDPOINT. +[ -n "${PASEO_RELAY_PUBLIC_ENDPOINT:-}" ] || unset PASEO_RELAY_PUBLIC_ENDPOINT + +: "${HOME:=/home/paseo}" +: "${PASEO_HOME:=$HOME/.paseo}" +: "${CODEX_HOME:=$HOME/.codex}" +export HOME PASEO_HOME CODEX_HOME BUZZ_PASEO_HOST_TOOLS +umask 077 +mkdir -p "$PASEO_HOME" "$CODEX_HOME/rules" "$HOME/assistant" "$HOME/.claude" +[ "$BUZZ_PASEO_HOST_TOOLS" != on ] || mkdir -p "$HOME/snapshots" + +# Validation runs before any file is written: the daemon silently replaces an +# unreadable keypair with a new one, which would break every paired client. +# The script prints the PA provider (claude, codex, or nothing) on stdout. +assistant=$(node --input-type=module - <<'EOF' +import { readFileSync, renameSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { createECDH } from "node:crypto"; +import path from "node:path"; + +const nacl = createRequire("/usr/local/lib/node_modules/@getpaseo/relay/")("tweetnacl"); +const env = process.env; +const fail = (message) => { + console.error(`paseo-buzz-entrypoint: ${message}`); + process.exit(64); +}; +const parse = (name) => { + try { + return JSON.parse(env[name]); + } catch { + return fail(`${name} is not valid JSON`); + } +}; +const key = (value) => { + if (typeof value !== "string") return null; + const bytes = Buffer.from(value, "base64"); + return bytes.length === 32 && bytes.toString("base64") === value ? bytes : null; +}; +// A credential as the buzz-agent profile accepts it: 1 to 8192 characters +// and no whitespace. +const credential = (name) => { + const value = env[name] ?? ""; + if (value === "") return ""; + if (value.length > 8192 || /[\s\p{Cc}]/u.test(value)) fail(`${name} is not a valid credential`); + return value; +}; +const HEX_PUBKEY = /^[0-9a-f]{64}$/; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +// host:port, as the sandbox manager checks the relay endpoint. +const ENDPOINT = /^[A-Za-z0-9.-]+:([1-9][0-9]{0,4})$/; +const hostTools = env.BUZZ_PASEO_HOST_TOOLS === "on"; + +if (credential("PASEO_PASSWORD").length < 16) fail("PASEO_PASSWORD must be at least 16 characters"); +// The daemon's own generator, and the sandbox manager, make srv_ and base64url. +if (!/^srv_[A-Za-z0-9_-]{8,64}$/.test(env.PASEO_SERVER_ID)) fail("PASEO_SERVER_ID must match ^srv_[A-Za-z0-9_-]{8,64}$"); +for (const name of ["PASEO_RELAY_ENDPOINT", "PASEO_RELAY_PUBLIC_ENDPOINT"]) { + const value = env[name]; + if (value === undefined) continue; + const match = ENDPOINT.exec(value); + if (!match || Number(match[1]) > 65535) fail(`${name} must be host:port, without a scheme or path`); +} +// The manager stores a 32-byte secp256k1 gateway secret in lowercase hex. +if (!/^[0-9a-f]{64}$/.test(env.BUZZ_PRIVATE_KEY)) fail("BUZZ_PRIVATE_KEY must be a 64-character hex key"); +try { createECDH("secp256k1").setPrivateKey(Buffer.from(env.BUZZ_PRIVATE_KEY, "hex")); } +catch { fail("BUZZ_PRIVATE_KEY must be a valid secp256k1 secret"); } +try { + const url = new URL(env.BUZZ_RELAY_URL); + if (url.protocol !== "wss:" || !url.hostname || url.username || url.password || url.hash) throw new Error(); +} catch { fail("BUZZ_RELAY_URL must be a valid wss:// relay URL without credentials or fragment"); } + +const keypair = parse("PASEO_DAEMON_KEYPAIR"); +const publicKey = key(keypair?.publicKeyB64); +const secretKey = key(keypair?.secretKeyB64); +if ( + keypair?.v !== 2 || + Object.keys(keypair).length !== 3 || + !publicKey || + !secretKey || + !Buffer.from(nacl.box.keyPair.fromSecretKey(secretKey).publicKey).equals(publicKey) +) { + fail('PASEO_DAEMON_KEYPAIR must be {"v":2,"publicKeyB64","secretKeyB64"} with a matching 32-byte Curve25519 keypair'); +} + +// The PA is Claude Code when its token is present, otherwise Codex. +const claudeToken = credential("CLAUDE_CODE_OAUTH_TOKEN"); +const codexToken = credential("CODEX_ACCESS_TOKEN"); +if (codexToken && !codexToken.startsWith("at-")) fail("CODEX_ACCESS_TOKEN must be a Codex access token (at-...)"); +const assistant = claudeToken ? "claude" : codexToken ? "codex" : ""; + +// Built-in providers and the providers of bundled plugins start disabled. +// Only the PA and the Buzz remote providers are enabled. +const reserved = ["claude", "codex", "copilot", "cursor", "opencode", "pi", "omp", "antigravity", "hoplite", "muse"]; +const providers = Object.fromEntries(reserved.map((id) => [id, { enabled: false }])); + +const agents = env.BUZZ_PASEO_AGENTS ? parse("BUZZ_PASEO_AGENTS") : []; +if (!Array.isArray(agents) || agents.length > 64) fail("BUZZ_PASEO_AGENTS must be a JSON array of at most 64 providers"); +const owner = env.BUZZ_PASEO_OWNER ?? ""; +if (owner && !HEX_PUBKEY.test(owner)) fail("BUZZ_PASEO_OWNER must be a 64-character hex pubkey"); +if (agents.length && !owner) fail("BUZZ_PASEO_OWNER is required with remote providers"); +for (const [index, agent] of agents.entries()) { + const where = `BUZZ_PASEO_AGENTS[${index}]`; + const { id, label, agent: pubkey, channel } = agent ?? {}; + if (typeof id !== "string" || !/^[a-z][a-z0-9-]*$/.test(id)) fail(`${where}.id must match ^[a-z][a-z0-9-]*$`); + if (Object.hasOwn(providers, id)) fail(`${where}.id "${id}" is reserved or duplicated`); + if (typeof label !== "string" || label.trim() === "") fail(`${where}.label must be a non-empty string`); + if (typeof pubkey !== "string" || !HEX_PUBKEY.test(pubkey)) fail(`${where}.agent must be a 64-character hex pubkey`); + if (typeof channel !== "string" || !UUID.test(channel)) fail(`${where}.channel must be a UUID`); + // buzz acp-bridge gets BUZZ_RELAY_URL and BUZZ_PRIVATE_KEY from the + // inherited environment, so config.json holds no secrets. The env overlay + // blanks the secrets that the bridge does not use. The bridge gets no Paseo + // tools, no MCP servers, no terminals, and no file access from the daemon. + providers[id] = { + extends: "acp", + label, + command: ["buzz", "acp-bridge", "--agent", pubkey, "--channel", channel, "--owner", owner], + env: { PASEO_PASSWORD: "", CLAUDE_CODE_OAUTH_TOKEN: "", CODEX_ACCESS_TOKEN: "" }, + options: { + supportsMcpServers: false, + clientCapabilities: { terminal: false, fs: { readTextFile: false, writeTextFile: false } }, + }, + paseoTools: { enabled: false }, + }; +} + +// The PA uses the Paseo tools to start and follow Buzz remote sessions and +// schedules. It never answers permission requests or changes modes, so the +// developer approves every command other than `buzz`. With host tools off, +// it also has no terminal or workspace tools. +const disabledTools = [ + "respond_to_permission", + "set_agent_mode", + "speak", + ...(hostTools + ? [] + : [ + "create_terminal", + "list_terminals", + "kill_terminal", + "capture_terminal", + "send_terminal_keys", + "list_workspace_scripts", + "start_workspace_script", + "stop_workspace_script", + "create_workspace", + "archive_workspace", + "rename_workspace", + ]), +]; +const blankSecrets = { PASEO_PASSWORD: "" }; +if (assistant === "claude") { + // Claude Code keeps Bash, so it can run `buzz`. Only `buzz` and the Paseo + // tools run without approval; any other command asks the paired developer. + providers.claude = { + enabled: true, + label: "Personal assistant", + env: blankSecrets, + disallowedTools: ["Read", "Write", "Edit", "MultiEdit", "NotebookEdit", "Glob", "Grep", "WebFetch", "WebSearch"], + options: { + settings: { + permissions: { allow: ["Bash(buzz:*)", "mcp__paseo"] }, + }, + }, + paseoTools: { enabled: true, disabledTools }, + }; +} else if (assistant === "codex") { + // Codex asks the developer for every command that its rules do not allow + // (rules/buzz.rules allows `buzz`). The container is the sandbox: it has + // no repositories and no system credentials. These options override every + // Paseo mode, including Full Access. + providers.codex = { + enabled: true, + label: "Personal assistant", + env: blankSecrets, + options: { approval_policy: "untrusted", sandbox_mode: "danger-full-access", web_search: "disabled" }, + paseoTools: { enabled: true, disabledTools }, + }; +} + +// The manager agent is optional static config, not part of the Secret. +const managerPubkey = env.BUZZ_MANAGER_PUBKEY ?? ""; +const managerChannel = env.BUZZ_MANAGER_CHANNEL ?? ""; +if (Boolean(managerPubkey) !== Boolean(managerChannel)) { + fail("set both BUZZ_MANAGER_PUBKEY and BUZZ_MANAGER_CHANNEL, or neither"); +} +if (managerPubkey && !HEX_PUBKEY.test(managerPubkey)) fail("BUZZ_MANAGER_PUBKEY must be a 64-character hex pubkey"); +if (managerChannel && !UUID.test(managerChannel)) fail("BUZZ_MANAGER_CHANNEL must be a UUID"); +const manager = managerPubkey + ? `The manager agent has the pubkey \`${managerPubkey}\` and works in the channel \`${managerChannel}\`.` + : [ + "No manager agent is configured. If a Buzz remote agent below is labeled as the manager, use its pubkey and channel.", + "Otherwise ask the developer for the manager's name, then find it with `buzz --format compact users get --name `.", + ].join(" "); + +const remoteAgents = agents.length + ? [ + "These Paseo providers each connect to one Buzz agent in one channel:", + "", + ...agents.map((agent) => `- \`${agent.id}\`: ${agent.label} (pubkey \`${agent.agent}\`, channel \`${agent.channel}\`)`), + ].join("\n") + : "No Buzz remote providers are configured. Use the buzz CLI to reach Buzz agents."; +const instructions = readFileSync("/etc/paseo-buzz/assistant.md", "utf8") + .replace("{{BUZZ_RELAY_URL}}", () => env.BUZZ_RELAY_URL) + .replace("{{MANAGER}}", () => manager) + .replace("{{REMOTE_AGENTS}}", () => remoteAgents); + +const recordChannel = env.BUZZ_PASEO_RECORD_CHANNEL ?? ""; +if (recordChannel && !UUID.test(recordChannel)) fail("BUZZ_PASEO_RECORD_CHANNEL must be a UUID"); + +const config = { + version: 1, + daemon: { + // The agent MCP server hosts the Paseo tools that the PA uses. Each + // provider's paseoTools policy limits them. + mcp: { enabled: true, injectIntoAgents: true }, + ...(hostTools ? { terminalProfiles: [{ id: "buzz-sandbox", name: "Buzz sandbox", command: "buzz", args: ["sandbox", "attach"] }] } : {}), + browserTools: { enabled: false }, + usage: { enabled: false }, + enableTerminalAgentHooks: false, + autoArchiveAfterMerge: false, + appendSystemPrompt: instructions, + }, + pluginsEnabled: true, + plugins: { "buzz-recorder": { source: "directory", path: "/opt/paseo-buzz/recorder", enabled: true } }, + agents: { providers }, + features: { + dictation: { enabled: false }, + voiceMode: { enabled: false }, + webUi: { enabled: false }, + }, +}; + +const write = (target, content) => { + const temporary = `${target}.tmp`; + writeFileSync(temporary, content, { mode: 0o600 }); + renameSync(temporary, target); +}; +write(path.join(env.HOME, ".claude", "settings.json"), `${JSON.stringify({ permissions: { disableBypassPermissionsMode: "disable" } })}\n`); +write(path.join(env.PASEO_HOME, "daemon-keypair.json"), `${JSON.stringify(keypair, null, 2)}\n`); +write(path.join(env.PASEO_HOME, "config.json"), `${JSON.stringify(config, null, 2)}\n`); +write(path.join(env.CODEX_HOME, "rules", "buzz.rules"), 'prefix_rule(pattern = ["buzz"], decision = "allow")\n'); + +const log = (message) => console.error(`paseo-buzz-entrypoint: ${message}`); +if (!assistant) log("no CLAUDE_CODE_OAUTH_TOKEN or CODEX_ACCESS_TOKEN: starting without the personal assistant"); +else log(`personal assistant: ${assistant}`); +log(`Buzz remote providers: ${agents.map((agent) => agent.id).join(", ") || "none"}`); +log(`host tools: ${hostTools ? "on" : "off"}`); +process.stdout.write(assistant); +EOF +) || exit $? + +# Only the selected PA keeps its model token. +[ "$assistant" = claude ] || unset CLAUDE_CODE_OAUTH_TOKEN +[ "$assistant" = codex ] || unset CODEX_ACCESS_TOKEN + +if [ "$BUZZ_PASEO_HOST_TOOLS" = off ]; then + # Paseo has no setting that turns terminals off. It copies its terminal + # worker into $PASEO_HOME/terminal-worker before it starts one. An empty, + # root-owned target makes every terminal request (client, MCP tool, or ACP) + # fail while the daemon keeps running. `git` fails for the daemon and its + # agents, so checkouts, clones, and worktrees fail too. + rm -rf "$PASEO_HOME/terminal-worker" + ln -s /opt/paseo-buzz/no-terminal-worker "$PASEO_HOME/terminal-worker" + PATH="/opt/paseo-buzz/host-tools-off:$PATH" + export PATH +elif [ -L "$PASEO_HOME/terminal-worker" ]; then + # A container restart keeps the emptyDir home: drop the link of an earlier + # start with host tools off. + rm "$PASEO_HOME/terminal-worker" +fi + +# The daemon reads the keypair file; keep the secret key out of every child +# process environment. +unset PASEO_DAEMON_KEYPAIR +cd "$HOME/assistant" +exec /usr/bin/tini -- /usr/local/bin/paseo-docker-entrypoint diff --git a/voltage/images/paseo-daemon/package-lock.json b/voltage/images/paseo-daemon/package-lock.json new file mode 100644 index 00000000000..3a9cc04a981 --- /dev/null +++ b/voltage/images/paseo-daemon/package-lock.json @@ -0,0 +1,137 @@ +{ + "name": "paseo-daemon-runtimes", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "paseo-daemon-runtimes", + "version": "0.0.0", + "dependencies": { + "@openai/codex": "0.159.3" + } + }, + "node_modules/@openai/codex": { + "version": "0.159.3", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.159.3.tgz", + "integrity": "sha512-hg4nlqCwRb87GocxNnHvz4sadOrdJ71mO0a4CUYWXjAozJe2gtuAdRFldKGSQXw9m7SgR4ydis12KDVVPgzDkQ==", + "license": "Apache-2.0", + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.159.3-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.159.3-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.159.3-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.159.3-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.159.3-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.159.3-win32-x64" + } + }, + "node_modules/@openai/codex-darwin-arm64": { + "name": "@openai/codex", + "version": "0.159.3-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.159.3-darwin-arm64.tgz", + "integrity": "sha512-aI4UY14YURYxJxnRK+AE4QU+aek0mgtyyo7Rw9rNbCQUYETRQ0NYdJzU9ytljERGpPlht3dHHI1u4NhqHoDJDQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-darwin-x64": { + "name": "@openai/codex", + "version": "0.159.3-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.159.3-darwin-x64.tgz", + "integrity": "sha512-KTOQOD184DMXpR3TqnDUnLsgad14WJn+x3XmjBaXRHchKYGttjREwqrwzC61xQYFeh6VkogAceybR+SMbbgvYQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-arm64": { + "name": "@openai/codex", + "version": "0.159.3-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.159.3-linux-arm64.tgz", + "integrity": "sha512-84C1yhAI+i0hqh77Akp/Ma09Z4d5oLKcZKqW1WZpEDh8bKbNvvbNEd6pE+GMUe2Kwg7jCbuvkRNH1zgK+SCM3Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-x64": { + "name": "@openai/codex", + "version": "0.159.3-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.159.3-linux-x64.tgz", + "integrity": "sha512-xlHydfOksnNt/qz4BEVFvjMWunweaVSNtha/DkVUwWZnVZxbqiJwsXSl2iZDk04b6k/g0OI7jU06XqzHB2s5og==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-arm64": { + "name": "@openai/codex", + "version": "0.159.3-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.159.3-win32-arm64.tgz", + "integrity": "sha512-4C+EuH1zE4rO5w1MsrnS98Tvq6Ff4+zeNr3pVeR/UsQ+VrTY5BCA9nZuXuCXNLKak1tLOw81b+fQM4jKGFCeDA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-x64": { + "name": "@openai/codex", + "version": "0.159.3-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.159.3-win32-x64.tgz", + "integrity": "sha512-h8w5nslfQyoYbonZaRlLwvPKFS9Mcxz5vUeCeoHKF5SsZUP9jSBH7hxUdc8RWJffJtfbV2qxdnImVAj9zy2fwA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + } + } +} diff --git a/voltage/images/paseo-daemon/package.json b/voltage/images/paseo-daemon/package.json new file mode 100644 index 00000000000..9e3a949da5e --- /dev/null +++ b/voltage/images/paseo-daemon/package.json @@ -0,0 +1,8 @@ +{ + "name": "paseo-daemon-runtimes", + "version": "0.0.0", + "private": true, + "dependencies": { + "@openai/codex": "0.159.3" + } +} diff --git a/voltage/images/paseo-daemon/recorder/index.server.ts b/voltage/images/paseo-daemon/recorder/index.server.ts new file mode 100644 index 00000000000..47db00b8bed --- /dev/null +++ b/voltage/images/paseo-daemon/recorder/index.server.ts @@ -0,0 +1 @@ +export { default } from "./server/recorder.mjs"; diff --git a/voltage/images/paseo-daemon/recorder/paseo-plugin.json b/voltage/images/paseo-daemon/recorder/paseo-plugin.json new file mode 100644 index 00000000000..b684f85892a --- /dev/null +++ b/voltage/images/paseo-daemon/recorder/paseo-plugin.json @@ -0,0 +1 @@ +{"id":"buzz-recorder","name":"Buzz turn recorder","requirements":{"paseo":">=0.15.3 <0.16.0"}} diff --git a/voltage/images/paseo-daemon/recorder/server/recorder.mjs b/voltage/images/paseo-daemon/recorder/server/recorder.mjs new file mode 100644 index 00000000000..4c555c22e3e --- /dev/null +++ b/voltage/images/paseo-daemon/recorder/server/recorder.mjs @@ -0,0 +1,170 @@ +import { createHash } from "node:crypto"; +import { mkdir, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { spawn } from "node:child_process"; + +const MAX_JOBS = 256; +const MAX_BYTES = 8 * 1024 * 1024; +const MAX_RECORD_BYTES = 24000; +const PAGE_SIZE = 100; +const MAX_PAGES = 10; +const log = (message) => console.error(`buzz-recorder: ${message}`); + +// Drop entire fields containing a credential, rather than risking partial redaction. +export function scrub(value, env = process.env) { + if (typeof value !== "string") return ""; + const secrets = Object.entries(env).filter(([key, secret]) => + /TOKEN|SECRET|PASSWORD|PRIVATE_KEY|API_KEY|KEYPAIR/.test(key) && secret && secret.length >= 6, + ).map(([, secret]) => secret); + if (secrets.some((secret) => value.includes(secret)) || + /-----BEGIN [^-]*PRIVATE KEY-----|\bnsec1[0-9a-z]+|\b(?:gh[pousr]_|github_pat_|xox[baprs]-|sk-[A-Za-z0-9-]|at-[A-Za-z0-9-])\S*|\bAKIA[A-Z0-9]{16}\b|\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|(?:password|secret|token|api[_-]?key)\s*[:=]\s*\S+|\b[A-Za-z0-9+/=_-]{40,}\b/i.test(value)) { + return "[field omitted: possible credential]"; + } + return value.length <= 6000 ? value : `${value.slice(0, 6000)}\n[truncated]`; +} + +function publish(content, channel) { + return new Promise((resolve, reject) => { + const child = spawn("buzz", ["messages", "send", "--channel", channel, "--content", "-"], { + stdio: ["pipe", "pipe", "ignore"], detached: true, + }); + let output = ""; + let overflow = false; + const kill = () => { + try { process.kill(-child.pid, "SIGKILL"); } catch (error) { + if (error.code !== "ESRCH") reject(new Error("cannot terminate publisher process group")); + } + }; + const timeout = setTimeout(kill, 15000); + child.stdout.on("data", (chunk) => { + if (Buffer.byteLength(output) + chunk.length > 65536) { overflow = true; kill(); } + else output += chunk; + }); + child.stdin.on("error", () => {}); // Exit/error below retains the queued job. + child.on("error", (error) => { clearTimeout(timeout); reject(error); }); + child.on("close", (code) => { + clearTimeout(timeout); + let result; + try { result = JSON.parse(output); } catch { /* Rejected below; job stays. */ } + if (code === 0 && !overflow && result?.accepted === true) resolve(); + else reject(new Error("relay did not acknowledge the turn record")); + }); + child.stdin.end(content); + }); +} + +async function timelineRecord(job, paseo) { + const timeline = paseo.agents.ref(job.agentId).timeline; + let cursor; + let entries = []; + let truncated = false; + for (let page = 0; page < MAX_PAGES; page++) { + const data = await timeline.refetch({ direction: cursor ? "before" : "tail", cursor, limit: PAGE_SIZE, projection: "canonical" }); + if (data.error || data.staleCursor || data.gap) throw new Error("timeline unavailable or changed; retry retained"); + const selected = data.entries.filter((entry) => entry.turnId === job.turnId); + entries = [...selected, ...entries]; + if (selected.some((entry) => entry.item.type === "user_message") || !data.hasOlder) break; + if (!data.startCursor) throw new Error("timeline lacks pagination cursor"); + cursor = data.startCursor; + truncated = page === MAX_PAGES - 1; + } + if (!entries.length) throw new Error("turn is absent from timeline; retry retained"); + const items = entries.map((entry) => entry.item); + const prompts = items.filter((item) => item.type === "user_message").map((item) => item.text).join("\n"); + const answers = items.filter((item) => item.type === "assistant_message").map((item) => item.text); + const tools = items.filter((item) => item.type === "tool_call").slice(0, 50) + .map((item) => `${scrub(item.name).slice(0, 120)}: ${["running", "completed", "failed", "canceled"].includes(item.status) ? item.status : "unknown"}`); + return [ + `Paseo personal assistant turn ${job.id} (${job.outcome})`, + `Prompt:\n${scrub(prompts)}`, + `Final answer:\n${scrub(answers.at(-1) ?? "")}`, + `Tools:\n${tools.join("\n") || "none"}`, + ...(truncated ? ["[timeline limited to 1000 entries]"] : []), + ].join("\n\n"); +} + +/** Persist a retry intent before reading timeline data or publishing to Buzz. */ +export function createRecorder({ directory, channel, send = publish, retryMs = 1000 }) { + let paseo; + let timer; + let closed = false; + let queue = Promise.resolve(); + let failures = 0; + const serialize = (fn) => { + const result = queue.then(fn); + queue = result.catch(() => {}); // Caller observes errors; queued files retain retries. + return result; + }; + const write = async (file, job) => { + await writeFile(`${file}.tmp`, JSON.stringify(job), { mode: 0o600 }); + await rename(`${file}.tmp`, file); + }; + const files = async () => { + await mkdir(directory, { recursive: true, mode: 0o700 }); + const names = await readdir(directory); + for (const name of names.filter((file) => /^[a-f0-9]{64}\.json\.tmp$/.test(file))) await unlink(path.join(directory, name)); + return names.filter((file) => /^[a-f0-9]{64}\.json$/.test(file)).sort(); + }; + const schedule = (delay) => { + if (closed || timer) return; + timer = setTimeout(() => { timer = undefined; flush().catch(() => {}); }, delay); + timer.unref?.(); + }; + const flush = () => serialize(async () => { + let pending = false; + const names = await files(); + if (names.length > 8) pending = true; + for (const name of names.slice(0, 8)) { + const file = path.join(directory, name); + try { + const job = JSON.parse(await readFile(file, "utf8")); + if (!job.content) { + if (!paseo) { pending = true; continue; } + job.content = await timelineRecord(job, paseo); + if (Buffer.byteLength(job.content) > MAX_RECORD_BYTES) throw new Error("turn record exceeds bound"); + await write(file, job); + } + await send(job.content, channel); + await unlink(file); // Only an explicit relay acknowledgement removes the journal. + } catch { + pending = true; + log("delivery failed; durable retry retained"); + } + } + failures = pending ? Math.min(failures + 1, 6) : 0; + if (pending) schedule(Math.min(60000, retryMs * 2 ** failures)); + }); + return { + observe(context) { paseo = context.paseo; schedule(0); }, + capture(payload, context) { + paseo = context.paseo; + return serialize(async () => { + if (!["claude", "codex"].includes(payload.agent.provider)) return; + if (!payload.turnId) throw new Error("cannot record a turn without an identity"); + const id = createHash("sha256").update(`${payload.agent.id}\0${payload.turnId}`).digest("hex"); + const names = await files(); + const file = path.join(directory, `${id}.json`); + if (!names.includes(`${id}.json`)) { + const sizes = await Promise.all(names.map((name) => stat(path.join(directory, name)))); + if (names.length >= MAX_JOBS || sizes.reduce((sum, info) => sum + info.size, 0) >= MAX_BYTES - MAX_RECORD_BYTES) { + throw new Error("recording spool full; turn recording refused"); + } + await write(file, { id, agentId: payload.agent.id, turnId: payload.turnId, outcome: payload.outcome.kind }); + } + schedule(0); + }); + }, + flush, + async close() { closed = true; clearTimeout(timer); await queue; }, + }; +} + +export default function contribute(server) { + const channel = process.env.BUZZ_PASEO_RECORD_CHANNEL; + if (!channel) { log("BUZZ_PASEO_RECORD_CHANNEL is unset; recording disabled"); return () => {}; } + const recorder = createRecorder({ directory: path.join(process.env.PASEO_HOME, "buzz-recording"), channel }); + const stopOpen = server.addHook("agent.open.before", (payload, context) => { recorder.observe(context); return payload; }); + const stopTurn = server.addHook("agent.turn.after", (payload, context) => recorder.capture(payload, context)); + recorder.flush().catch(() => log("startup replay failed; retry files retained")); + return async () => { stopOpen(); stopTurn(); await recorder.close(); }; +} diff --git a/voltage/images/paseo-daemon/test-image.sh b/voltage/images/paseo-daemon/test-image.sh new file mode 100644 index 00000000000..a935e331d77 --- /dev/null +++ b/voltage/images/paseo-daemon/test-image.sh @@ -0,0 +1,259 @@ +#!/usr/bin/env bash +# Image contract test for the Paseo daemon image. Runs the container with the +# hardened flags, synthetic secrets, and no network: no relay, Buzz relay, or +# credentials are needed. +# bash voltage/images/paseo-daemon/test-image.sh [image] +# Without an image argument, it builds paseo-daemon:local from this checkout. +set -euo pipefail +trap 'printf "FAIL: image contract exited at line %s\n" "$LINENO" >&2' ERR +IMAGE="${1:-}" +if [ -z "$IMAGE" ]; then + IMAGE=paseo-daemon:local + root="$(cd "$(dirname "$0")/../../.." && pwd)" + docker build -f "$root/voltage/images/paseo-daemon/Dockerfile" -t "$IMAGE" "$root" +fi + +NAME="paseo-daemon-test-$$" +containers=("$NAME" "$NAME-pa-off" "$NAME-optional") +stubs=$(mktemp -d) +cleanup() { + docker rm -f "${containers[@]}" >/dev/null 2>&1 || true + rm -rf "$stubs" +} +trap cleanup EXIT +[ -z "${KEEP:-}" ] || trap - EXIT +fail() { printf 'FAIL: %s\n' "$*" >&2; exit 1; } + +# shellcheck disable=SC2054 # the tmpfs options are comma-separated +HARDENED=(--network none --memory 1g --read-only --cap-drop ALL --security-opt no-new-privileges --user 1000:1000 + --tmpfs /home/paseo:uid=1000,gid=1000,mode=0700 --tmpfs /tmp) +KEYPAIR=$(docker run --rm --network none --entrypoint node "$IMAGE" -e ' + const nacl = require("/usr/local/lib/node_modules/@getpaseo/relay/node_modules/tweetnacl"); + const pair = nacl.box.keyPair(); + const b64 = (bytes) => Buffer.from(bytes).toString("base64"); + console.log(JSON.stringify({ v: 2, publicKeyB64: b64(pair.publicKey), secretKeyB64: b64(pair.secretKey) })); +') +PUBLIC_KEY=$(jq -r .publicKeyB64 <<<"$KEYPAIR") +SECRET_KEY=$(jq -r .secretKeyB64 <<<"$KEYPAIR") +PASSWORD="test-password-$RANDOM$RANDOM" +PRIVATE_KEY=$(printf '%064x' 1) +TOKEN="sk-ant-oat01-test-$RANDOM$RANDOM" +AGENT=$(printf 'a%.0s' {1..64}) +MANAGER=$(printf 'b%.0s' {1..64}) +CHANNEL=6f1c1b2e-3d4a-4b5c-8d9e-0f1a2b3c4d5e +SECRETS=(-e "$PASSWORD" -e "$SECRET_KEY" -e "$PRIVATE_KEY" -e "$TOKEN") +BASE_ENV=( + -e PASEO_PASSWORD="$PASSWORD" + -e PASEO_SERVER_ID=srv_paseodaemontest + -e PASEO_DAEMON_KEYPAIR="$KEYPAIR" + -e PASEO_RELAY_ENDPOINT=relay.invalid:443 + -e BUZZ_RELAY_URL=wss://buzz.invalid + -e BUZZ_PRIVATE_KEY="$PRIVATE_KEY" + -e BUZZ_PASEO_OWNER="$MANAGER" + -e BUZZ_PASEO_AGENTS='[{"id":"brokkr","label":"Brokkr","agent":"'"$AGENT"'","channel":"'"$CHANNEL"'"}]' +) +ENV=("${BASE_ENV[@]}" -e CLAUDE_CODE_OAUTH_TOKEN="$TOKEN" -e BUZZ_MANAGER_PUBKEY="$MANAGER" -e BUZZ_MANAGER_CHANNEL="$CHANNEL") + +# The wrapper refuses unsafe or malformed settings before the daemon starts. +refuses() { + local expect="$1" + shift + local output status=0 + output=$(docker run --rm "${HARDENED[@]}" "${ENV[@]}" "$@" "$IMAGE" 2>&1) || status=$? + [ "$status" = 64 ] || fail "expected exit 64 for '$expect', got $status: $output" + grep -qF "$expect" <<<"$output" || fail "expected '$expect' in: $output" + ! grep -qF "${SECRETS[@]}" <<<"$output" || fail "a secret was printed" +} +refuses "BUZZ_RELAY_URL must start with wss://" -e BUZZ_RELAY_URL=ws://buzz.invalid +refuses "BUZZ_RELAY_URL must start with wss://" -e BUZZ_RELAY_URL=https://buzz.invalid +refuses "BUZZ_PRIVATE_KEY must be a valid secp256k1 secret" -e BUZZ_PRIVATE_KEY="$(printf '%064x' 0)" +refuses "PASEO_RELAY_USE_TLS must be true" -e PASEO_RELAY_USE_TLS=false +refuses "PASEO_RELAY_USE_TLS must be true" -e PASEO_RELAY_USE_TLS= +refuses "PASEO_RELAY_PUBLIC_USE_TLS must be true" -e PASEO_RELAY_PUBLIC_USE_TLS=0 +refuses "PASEO_RELAY_ENDPOINT must be host:port" -e PASEO_RELAY_ENDPOINT=wss://relay.invalid:443 +for name in PASEO_PASSWORD PASEO_SERVER_ID PASEO_DAEMON_KEYPAIR PASEO_RELAY_ENDPOINT BUZZ_RELAY_URL BUZZ_PRIVATE_KEY; do + refuses "$name is required" -e "$name=" +done +refuses "PASEO_DAEMON_KEYPAIR is not valid JSON" -e PASEO_DAEMON_KEYPAIR='{"v":2,' +refuses "PASEO_DAEMON_KEYPAIR must be" -e PASEO_DAEMON_KEYPAIR="$(jq -c '.v = 1' <<<"$KEYPAIR")" +refuses "PASEO_DAEMON_KEYPAIR must be" -e PASEO_DAEMON_KEYPAIR="$(jq -c '.publicKeyB64 = "AAAA"' <<<"$KEYPAIR")" +# A public key that does not belong to the secret key would pair clients to a +# daemon that cannot decrypt their traffic. +refuses "PASEO_DAEMON_KEYPAIR must be" -e PASEO_DAEMON_KEYPAIR="$(jq -c --arg k "$SECRET_KEY" '.publicKeyB64 = $k' <<<"$KEYPAIR")" +refuses "BUZZ_PASEO_AGENTS[0].id must match" -e BUZZ_PASEO_AGENTS='[{"id":"Bad_Id","label":"x","agent":"'"$AGENT"'","channel":"'"$CHANNEL"'"}]' +refuses 'BUZZ_PASEO_AGENTS[0].id "claude" is reserved' -e BUZZ_PASEO_AGENTS='[{"id":"claude","label":"x","agent":"'"$AGENT"'","channel":"'"$CHANNEL"'"}]' +refuses "BUZZ_PASEO_AGENTS[0].channel must be a UUID" -e BUZZ_PASEO_AGENTS='[{"id":"brokkr","label":"x","agent":"'"$AGENT"'","channel":"general"}]' +refuses "BUZZ_PASEO_OWNER is required" -e BUZZ_PASEO_OWNER= +refuses "BUZZ_PASEO_RECORD_CHANNEL must be a UUID" -e BUZZ_PASEO_RECORD_CHANNEL=general +refuses "set both BUZZ_MANAGER_PUBKEY and BUZZ_MANAGER_CHANNEL" -e BUZZ_MANAGER_CHANNEL= + +wait_running() { + local name="$1" status="" + for _ in $(seq 60); do + status=$(docker exec "$name" paseo status --json 2>/dev/null) && jq -e ' .localDaemon == "running" and (.providers | type == "array") and (.relay | type == "object") ' <<<"$status" >/dev/null && break + [ "$(docker inspect -f '{{.State.Running}}' "$name")" = true ] || { docker logs "$name" >&2; fail "$name exited"; } + status="" + sleep 1 + done + [ -n "$status" ] || { docker logs "$name" >&2; fail "$name did not report running"; } + printf '%s' "$status" +} + +# Without the model token, the daemon offers only the Buzz remote providers. +docker run -d --name "$NAME-pa-off" "${HARDENED[@]}" "${BASE_ENV[@]}" "$IMAGE" >/dev/null +status=$(wait_running "$NAME-pa-off") +[ "$(jq -c '[.providers[].provider]' <<<"$status")" = '["brokkr"]' ] || fail "providers without the token: $status" +docker rm -f "$NAME-pa-off" >/dev/null + +# Every optional-missing combination starts without a model or remote provider. +for agents in missing '[]'; do + if [ "$agents" = missing ]; then agent_env=(); else agent_env=(-e BUZZ_PASEO_AGENTS="$agents"); fi + docker run -d --name "$NAME-optional" "${HARDENED[@]}" "${BASE_ENV[@]}" -e BUZZ_PASEO_AGENTS= "${agent_env[@]}" "$IMAGE" >/dev/null + optional_status=$(wait_running "$NAME-optional") + [ "$(jq '.providers | length' <<<"$optional_status")" = 0 ] || fail "optional providers: $optional_status" + docker rm -f "$NAME-optional" >/dev/null +done +# Codex is the fallback; Claude wins if both tokens are set. Host profiles are opt-in. +for provider in codex claude; do + token_env=(-e CODEX_ACCESS_TOKEN=at-test-token) + [ "$provider" != claude ] || token_env+=(-e CLAUDE_CODE_OAUTH_TOKEN="$TOKEN") + docker run -d --name "$NAME-optional" "${HARDENED[@]}" "${BASE_ENV[@]}" "${token_env[@]}" -e BUZZ_PASEO_HOST_TOOLS=on "$IMAGE" >/dev/null + optional_status=$(wait_running "$NAME-optional") + jq -e --arg provider "$provider" '[.providers[].provider] | index($provider) != null' <<<"$optional_status" >/dev/null || fail "PA fallback: $optional_status" + docker exec "$NAME-optional" node -e ' + const fs = require("node:fs"); + const config = JSON.parse(fs.readFileSync("/home/paseo/.paseo/config.json")); + if (config.daemon.terminalProfiles[0].command !== "buzz" || config.daemon.terminalProfiles[0].args.join(" ") !== "sandbox attach") process.exit(1); + if (!fs.statSync("/home/paseo/snapshots").isDirectory()) process.exit(1); + ' || fail "host profile" + docker rm -f "$NAME-optional" >/dev/null +done + +# Stubs record how the daemon launches the PA and the bridge. They sit first +# on PATH, so the daemon resolves them in place of the image binaries. +cat >"$stubs/claude" <<'STUB' +#!/bin/sh +case "$*" in + *--version*) echo "2.1.246 (Claude Code)"; exit 0 ;; + *"auth status"*) echo '{"loggedIn":true}'; exit 0 ;; +esac +# Metadata generation also invokes Claude. Keep only the real assistant launch. +case "$*" in *--disallowedTools* | *--mcp-config*) ;; *) exit 1 ;; esac +printf '%s\n' "$@" >/tmp/claude.args +# The SDK sends the system prompt in its first stdin message. +timeout 10 head -n 1 >/tmp/claude.stdin +env >/tmp/claude.env +exit 1 +STUB +cat >"$stubs/buzz" <<'STUB' +#!/bin/sh +printf '%s\n' "$@" >/tmp/buzz.args +env >/tmp/buzz.env +exit 1 +STUB +chmod 0755 "$stubs/claude" "$stubs/buzz" +STUB_PATH=/tmp/stubs:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +# Startup with the hardened flags. +docker run -d --name "$NAME" "${HARDENED[@]}" "${ENV[@]}" -v "$stubs:/tmp/stubs:ro" -e PATH="$STUB_PATH" "$IMAGE" >/dev/null +status=$(wait_running "$NAME") +[ "$(docker exec "$NAME" id -u)" = 1000 ] || fail "not uid 1000" +[ "$(jq -r .serverId <<<"$status")" = srv_paseodaemontest ] || fail "server id: $status" +[ "$(jq -r .listen <<<"$status")" = 127.0.0.1:6767 ] || fail "listener is not loopback: $status" +jq -e '.relay | .enabled and .useTls and .publicUseTls and .endpoint == "relay.invalid:443"' <<<"$status" >/dev/null || + fail "relay settings: $status" +# The image's own claude is the Claude Code binary bundled with the daemon. +docker exec -e PATH=/usr/local/bin:/usr/bin:/bin "$NAME" claude --version | grep -q '(Claude Code)' || + fail "the image claude is not Claude Code" + +# The PA and the Buzz agent are offered; the other bundled providers are not. +[ "$(jq -c '[.providers[].provider] | sort' <<<"$status")" = '["brokkr","claude"]' ] || fail "enabled providers: $status" +docker exec "$NAME" paseo provider ls --json | + jq -e '(map(select(.enabled == "Enabled")) | map(.provider) | sort) == ["brokkr", "claude"] and (map(.provider) | index("codex"))' >/dev/null || + fail "provider list: $(docker exec "$NAME" paseo provider ls --json)" + +# The pairing offer carries the provided identity. +offer=$(docker exec "$NAME" paseo daemon pair --json | jq -r .url | sed 's/.*#offer=//') +docker exec "$NAME" node -e ' + const offer = JSON.parse(Buffer.from(process.argv[1], "base64url").toString()); + const ok = offer.serverId === "srv_paseodaemontest" && offer.daemonPublicKeyB64 === process.argv[2] && + offer.relay?.endpoint === "relay.invalid:443" && offer.relay?.useTls === true; + if (!ok) { console.error(JSON.stringify({ ...offer, daemonPublicKeyB64: undefined })); process.exit(1); } +' "$offer" "$PUBLIC_KEY" || fail "pairing offer does not match the provided identity" + +# The bridge gets the Buzz relay and key, and no Paseo password or model token. +docker exec -w /home/paseo/assistant "$NAME" paseo run --no-wait --provider brokkr "hello" >/dev/null 2>&1 || true +for _ in $(seq 30); do docker exec "$NAME" test -s /tmp/buzz.env && break; sleep 1; done +[ "$(docker exec "$NAME" cat /tmp/buzz.args | paste -sd' ')" = "acp-bridge --agent $AGENT --channel $CHANNEL --owner $MANAGER" ] || + fail "bridge command: $(docker exec "$NAME" cat /tmp/buzz.args 2>&1)" +bridge_env=$(docker exec "$NAME" cat /tmp/buzz.env) +grep -qx "BUZZ_PRIVATE_KEY=$PRIVATE_KEY" <<<"$bridge_env" || fail "the bridge has no BUZZ_PRIVATE_KEY" +grep -qx "BUZZ_RELAY_URL=wss://buzz.invalid" <<<"$bridge_env" || fail "the bridge has no BUZZ_RELAY_URL" +! grep -qF -e "$PASSWORD" -e "$TOKEN" -e "$SECRET_KEY" <<<"$bridge_env" || fail "the bridge received a secret it does not use" + +# The PA gets its token, the Buzz key, the instructions, and the Paseo tools, +# without file or web tools and without the Paseo password. +docker exec -w /home/paseo/assistant "$NAME" paseo run --no-wait --provider claude/sonnet "hello" >/dev/null 2>&1 || true +for _ in $(seq 30); do docker exec "$NAME" test -s /tmp/claude.env && break; sleep 1; done +claude_env=$(docker exec "$NAME" cat /tmp/claude.env 2>&1) +grep -qx "CLAUDE_CODE_OAUTH_TOKEN=$TOKEN" <<<"$claude_env" || fail "the PA has no model token" +grep -qx "BUZZ_PRIVATE_KEY=$PRIVATE_KEY" <<<"$claude_env" || fail "the PA has no BUZZ_PRIVATE_KEY" +! grep -qF -e "$PASSWORD" -e "$SECRET_KEY" <<<"$claude_env" || fail "the PA received the Paseo password or keypair" +claude_args=$(docker exec "$NAME" cat /tmp/claude.args) +disallowed=$(grep -A1 -x -- --disallowedTools <<<"$claude_args" | tail -1) +for tool in Read Write Edit WebFetch WebSearch; do + tr ',' '\n' <<<"$disallowed" | grep -qx "$tool" || fail "the PA can use $tool: $disallowed" +done +init=$(docker exec "$NAME" cat /tmp/claude.stdin) +grep -qF "personal assistant of one developer" <<<"$init" || fail "the PA instructions are missing: ${init:0:300}" +grep -qF "$MANAGER" <<<"$init" && grep -qF "$CHANNEL" <<<"$init" || fail "the manager is missing from the PA instructions" +mcp_config=$(grep -A1 -x -- --mcp-config <<<"$claude_args" | tail -1) +tools=$(docker exec "$NAME" node -e ' + const server = JSON.parse(process.argv[1]).mcpServers.paseo; + const headers = { ...server.headers, "content-type": "application/json", accept: "application/json, text/event-stream" }; + const call = async (body, session) => { + const response = await fetch(server.url, { method: "POST", headers: { ...headers, ...(session ? { "mcp-session-id": session } : {}) }, body: JSON.stringify(body) }); + const text = await response.text(); + const data = text.startsWith("{") ? text : text.split("\n").filter((line) => line.startsWith("data:")).map((line) => line.slice(5)).pop(); + return { session: response.headers.get("mcp-session-id"), result: JSON.parse(data).result }; + }; + (async () => { + const init = await call({ jsonrpc: "2.0", id: 1, method: "initialize", params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test", version: "0" } } }); + await fetch(server.url, { method: "POST", headers: { ...headers, ...(init.session ? { "mcp-session-id": init.session } : {}) }, body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }) }); + const list = await call({ jsonrpc: "2.0", id: 2, method: "tools/list" }, init.session); + console.log(list.result.tools.map((tool) => tool.name).sort().join("\n")); + })().catch((error) => { console.error(error); process.exit(1); }); +' "$mcp_config") || fail "could not list the PA Paseo tools" +for tool in create_agent send_agent_prompt get_agent_status list_providers; do + grep -qx "$tool" <<<"$tools" || fail "the PA lacks the Paseo tool $tool: $tools" +done +! grep -E '^(create_terminal|send_terminal_keys|start_workspace_script|create_workspace)$' <<<"$tools" || + fail "the PA has a Paseo tool that runs commands: $tools" + +# Terminals are off for clients as well. +workspace=$(docker exec "$NAME" paseo workspace ls --json | jq -r '.[0].workspaceId // .[0].id // empty') +[ -n "$workspace" ] || fail "no workspace: $(docker exec "$NAME" paseo workspace ls --json)" +terminal=$(docker exec "$NAME" paseo terminal create --workspace "$workspace" --cwd /home/paseo/assistant --json 2>&1 || true) +grep -q TERMINAL_CREATE_FAILED <<<"$terminal" || fail "a terminal was created: $terminal" + +# Secrets stay out of logs and out of the daemon environment. +logs=$(docker logs "$NAME" 2>&1) +! grep -qF "${SECRETS[@]}" <<<"$logs" || fail "a secret was logged" +! docker exec "$NAME" sh -c 'tr "\0" "\n" < /proc/1/environ' | grep -q '^PASEO_DAEMON_KEYPAIR=' || + fail "PASEO_DAEMON_KEYPAIR reached the daemon environment" +[ "$(docker exec "$NAME" stat -c %a /home/paseo/.paseo/daemon-keypair.json)" = 600 ] || fail "keypair file mode" +[ "$(docker exec "$NAME" stat -c %a /home/paseo/.paseo/config.json)" = 600 ] || fail "config file mode" +docker exec "$NAME" cat /home/paseo/.claude/settings.json | jq -e '.permissions.disableBypassPermissionsMode == "disable"' >/dev/null || fail "Claude bypass protection" + +# Nothing is written outside the tmpfs mounts. +[ "$(docker inspect -f '{{.HostConfig.ReadonlyRootfs}}' "$NAME")" = true ] || fail "root filesystem is writable" +changes=$(docker diff "$NAME") +[ -z "$changes" ] || fail "writes outside tmpfs: $changes" + +# The bundled plugin compiles and starts in the unmodified daemon. +docker exec "$NAME" paseo plugin ls --json >"$stubs/plugins.json" +jq -e 'any(.[]; .id == "buzz-recorder" and .status == "running")' "$stubs/plugins.json" >/dev/null || fail "recorder plugin did not load" +node "$(dirname "$0")/test-recorder.mjs" + +docker stats --no-stream --format ' {{.MemUsage}} container memory' "$NAME" +printf 'PASS: Paseo daemon image contract (%s)\n' "$IMAGE" diff --git a/voltage/images/paseo-daemon/test-recorder.mjs b/voltage/images/paseo-daemon/test-recorder.mjs new file mode 100644 index 00000000000..23c2411a96c --- /dev/null +++ b/voltage/images/paseo-daemon/test-recorder.mjs @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile, chmod } from "node:fs/promises"; +import path from "node:path"; +import os from "node:os"; +import { createRecorder, scrub } from "./recorder/server/recorder.mjs"; + +const root = await mkdtemp(path.join(os.tmpdir(), "buzz-recorder-test-")); +const spool = path.join(root, "spool"); +await mkdir(spool); +const channel = "6f1c1b2e-3d4a-4b5c-8d9e-0f1a2b3c4d5e"; +let available = true; +const payload = { agent: { id: "agent-1", provider: "claude" }, turnId: "turn-1", outcome: { kind: "completed" } }; +const entries = [ + { turnId: "turn-1", item: { type: "user_message", text: "Please investigate" } }, + { turnId: "turn-1", item: { type: "tool_call", name: "Bash", status: "completed", output: "NEVER COPY RAW OUTPUT" } }, + { turnId: "turn-1", item: { type: "assistant_message", text: "Fixed the failure" } }, + { turnId: "turn-2", item: { type: "assistant_message", text: "OTHER TURN" } }, +]; +const context = { paseo: { agents: { ref: () => ({ timeline: { refetch: async () => { + if (!available) throw new Error("offline"); + return { entries, hasOlder: false }; +} } }) } } }; +const options = { directory: spool, channel, retryMs: 60000 }; +let recorder; +try { + for (const value of ["ghp_abc123", "sk-ant-token", "password=secret", "nsec1abc", "at-session", "eyJh.e30.signature"]) assert.match(scrub(value, {}), /omitted/); + assert.match(scrub("credential opaque-secret", { MODEL_TOKEN: "opaque-secret" }), /omitted/); + assert.equal(scrub("ordinary words", {}), "ordinary words"); + const messages = []; + recorder = createRecorder({ ...options, send: async (content) => { messages.push(content); throw new Error("relay unavailable"); } }); + await recorder.capture({ ...payload, agent: { ...payload.agent, provider: "brokkr" } }, context); + assert.equal((await readdir(spool)).length, 0, "remote providers must not duplicate relay messages"); + available = false; + await recorder.capture(payload, context); + await recorder.flush(); + assert.equal((await readdir(spool)).length, 1, "timeline failure must retain retry intent"); + available = true; + await recorder.flush(); + assert.equal((await readdir(spool)).length, 1, "publish failure must retain record"); + const job = JSON.parse(await readFile(path.join(spool, (await readdir(spool))[0]))); + assert.match(job.content, /Please investigate/); + assert.match(job.content, /Fixed the failure/); + assert.match(job.content, /Bash: completed/); + assert.doesNotMatch(job.content, /NEVER COPY RAW OUTPUT|OTHER TURN/); + await recorder.close(); + + // Restart with a real child publisher that receives stdin. No relay acknowledgement keeps the record. + await writeFile(path.join(root, "buzz"), '#!/bin/sh\ncat >"$RECORD_TEST_OUTPUT"\nprintf \'{"accepted":false}\\n\'\n'); + await chmod(path.join(root, "buzz"), 0o755); + process.env.PATH = `${root}:${process.env.PATH}`; + process.env.RECORD_TEST_OUTPUT = path.join(root, "sent"); + recorder = createRecorder(options); + await recorder.flush(); + assert.equal((await readdir(spool)).length, 1); + await writeFile(path.join(root, "buzz"), '#!/bin/sh\ncat >"$RECORD_TEST_OUTPUT"\nprintf \'{"accepted":true}\\n\'\n'); + await recorder.flush(); + assert.equal((await readdir(spool)).length, 0); + assert.match(await readFile(path.join(root, "sent"), "utf8"), /Fixed the failure/); + await recorder.close(); + // Capacity exhaustion rejects the new recording without evicting accepted retry records. + recorder = createRecorder(options); + for (let index = 0; index < 256; index++) await writeFile(path.join(spool, `${index.toString(16).padStart(64, "0")}.json`), '{"content":"existing retry"}'); + await assert.rejects(recorder.capture({ ...payload, turnId: "new-turn" }, context), /spool full/); + assert.equal((await readdir(spool)).length, 256); + await recorder.close(); + console.log("PASS: recorder native-only filtering, redaction, turn fencing, journal retries, restart, acknowledgement and capacity refusal"); +} finally { await recorder?.close(); await rm(root, { recursive: true, force: true }); } From 90015f19b1e94bdcb0f889e24772aaaf32fe1181 Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 14:19:54 -0500 Subject: [PATCH 2/3] fix(paseo): bound turn recording queues and byte budgets Signed-off-by: Tee8z --- voltage/images/paseo-daemon/README.md | 2 + voltage/images/paseo-daemon/assistant.md | 4 +- .../paseo-daemon/recorder/server/recorder.mjs | 56 +++++++++++---- voltage/images/paseo-daemon/test-recorder.mjs | 70 +++++++++++++++++++ 4 files changed, 118 insertions(+), 14 deletions(-) diff --git a/voltage/images/paseo-daemon/README.md b/voltage/images/paseo-daemon/README.md index 3caa9dd129a..233a357097f 100644 --- a/voltage/images/paseo-daemon/README.md +++ b/voltage/images/paseo-daemon/README.md @@ -91,6 +91,8 @@ Retry intents are written before timeline reads or publication. The plugin removes a journal entry only after `buzz` returns `accepted: true`. Retries back off to 60 seconds; publisher process groups have a 15-second deadline and bounded output. The spool holds at most 256 records and eight MiB under `$PASEO_HOME/buzz-recording`. +At most 256 capture identities can wait in memory; excess captures fail before entering the queue. +Each timeline fetch has a five-second deadline. A full spool reports an error instead of claiming the turn was recorded. Records ready for publication replay at plugin startup. diff --git a/voltage/images/paseo-daemon/assistant.md b/voltage/images/paseo-daemon/assistant.md index 5d13b82250b..9d3107ee16e 100644 --- a/voltage/images/paseo-daemon/assistant.md +++ b/voltage/images/paseo-daemon/assistant.md @@ -51,8 +51,8 @@ Each write returns an `event_id`. Keep it, so you can read the thread later and # Workflows and gateway access -Manage automations with `buzz workflows list/get/create/update/delete/trigger/runs/approve`. Use Buzz workflows; do not create daemon-local schedules. +Use `buzz workflows list/get/create/update/delete/trigger/runs/approve` within your own channel permissions. Workflows belong to the key that creates them; the gateway grant does not let you modify the owner's workflows or approve for them. Ask the owner or delegate through an authorized remote manager when ordinary permissions refuse an action. Use Buzz workflows; do not create daemon-local schedules. -If Buzz rejects your requests, ask the owner to grant this daemon gateway key with `buzz gateway grant` or `buzz onboard`. Never request the owner's private key. +If Buzz refuses an agent-driving request because the gateway is not delegated, ask the owner to grant this daemon key with `buzz gateway grant` or `buzz onboard`. Community and channel membership are separate prerequisites. Other permission errors need the ordinary owner or administrator; renewing a gateway grant does not fix them. Never request the owner's private key. Each Buzz thread owns an isolated sandbox, which provides workspace isolation. You do not need local worktrees. diff --git a/voltage/images/paseo-daemon/recorder/server/recorder.mjs b/voltage/images/paseo-daemon/recorder/server/recorder.mjs index 4c555c22e3e..17913b2b21b 100644 --- a/voltage/images/paseo-daemon/recorder/server/recorder.mjs +++ b/voltage/images/paseo-daemon/recorder/server/recorder.mjs @@ -8,8 +8,17 @@ const MAX_BYTES = 8 * 1024 * 1024; const MAX_RECORD_BYTES = 24000; const PAGE_SIZE = 100; const MAX_PAGES = 10; +const TIMELINE_TIMEOUT_MS = 5000; const log = (message) => console.error(`buzz-recorder: ${message}`); +function utf8Prefix(value, budget) { + const bytes = Buffer.from(value, "utf8"); + if (bytes.length <= budget) return value; + let end = budget; + while (end > 0 && (bytes[end] & 0xc0) === 0x80) end -= 1; + return bytes.subarray(0, end).toString("utf8"); +} + // Drop entire fields containing a credential, rather than risking partial redaction. export function scrub(value, env = process.env) { if (typeof value !== "string") return ""; @@ -20,7 +29,9 @@ export function scrub(value, env = process.env) { /-----BEGIN [^-]*PRIVATE KEY-----|\bnsec1[0-9a-z]+|\b(?:gh[pousr]_|github_pat_|xox[baprs]-|sk-[A-Za-z0-9-]|at-[A-Za-z0-9-])\S*|\bAKIA[A-Z0-9]{16}\b|\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+|(?:password|secret|token|api[_-]?key)\s*[:=]\s*\S+|\b[A-Za-z0-9+/=_-]{40,}\b/i.test(value)) { return "[field omitted: possible credential]"; } - return value.length <= 6000 ? value : `${value.slice(0, 6000)}\n[truncated]`; + const suffix = "\n[truncated]"; + return Buffer.byteLength(value, "utf8") <= 6000 ? value : + `${utf8Prefix(value, 6000 - Buffer.byteLength(suffix))}${suffix}`; } function publish(content, channel) { @@ -59,7 +70,7 @@ async function timelineRecord(job, paseo) { let entries = []; let truncated = false; for (let page = 0; page < MAX_PAGES; page++) { - const data = await timeline.refetch({ direction: cursor ? "before" : "tail", cursor, limit: PAGE_SIZE, projection: "canonical" }); + const data = await timeline.refetch({ direction: cursor ? "before" : "tail", cursor, limit: PAGE_SIZE, projection: "canonical", timeout: TIMELINE_TIMEOUT_MS }); if (data.error || data.staleCursor || data.gap) throw new Error("timeline unavailable or changed; retry retained"); const selected = data.entries.filter((entry) => entry.turnId === job.turnId); entries = [...selected, ...entries]; @@ -73,7 +84,7 @@ async function timelineRecord(job, paseo) { const prompts = items.filter((item) => item.type === "user_message").map((item) => item.text).join("\n"); const answers = items.filter((item) => item.type === "assistant_message").map((item) => item.text); const tools = items.filter((item) => item.type === "tool_call").slice(0, 50) - .map((item) => `${scrub(item.name).slice(0, 120)}: ${["running", "completed", "failed", "canceled"].includes(item.status) ? item.status : "unknown"}`); + .map((item) => `${utf8Prefix(scrub(item.name), 120)}: ${["running", "completed", "failed", "canceled"].includes(item.status) ? item.status : "unknown"}`); return [ `Paseo personal assistant turn ${job.id} (${job.outcome})`, `Prompt:\n${scrub(prompts)}`, @@ -90,6 +101,8 @@ export function createRecorder({ directory, channel, send = publish, retryMs = 1 let closed = false; let queue = Promise.resolve(); let failures = 0; + let pendingCaptures = 0; + let flushing; const serialize = (fn) => { const result = queue.then(fn); queue = result.catch(() => {}); // Caller observes errors; queued files retain retries. @@ -106,11 +119,14 @@ export function createRecorder({ directory, channel, send = publish, retryMs = 1 return names.filter((file) => /^[a-f0-9]{64}\.json$/.test(file)).sort(); }; const schedule = (delay) => { - if (closed || timer) return; + if (closed || timer || flushing) return; timer = setTimeout(() => { timer = undefined; flush().catch(() => {}); }, delay); timer.unref?.(); }; - const flush = () => serialize(async () => { + const flush = () => { + if (flushing) return flushing; + let retryDelay; + flushing = serialize(async () => { let pending = false; const names = await files(); if (names.length > 8) pending = true; @@ -132,16 +148,32 @@ export function createRecorder({ directory, channel, send = publish, retryMs = 1 } } failures = pending ? Math.min(failures + 1, 6) : 0; - if (pending) schedule(Math.min(60000, retryMs * 2 ** failures)); - }); + if (pending) retryDelay = Math.min(60000, retryMs * 2 ** failures); + }).finally(() => { + flushing = undefined; + if (retryDelay !== undefined) schedule(retryDelay); + }); + return flushing; + }; return { observe(context) { paseo = context.paseo; schedule(0); }, capture(payload, context) { + if (!["claude", "codex"].includes(payload.agent.provider)) return Promise.resolve(); + const agentId = payload.agent.id; + const turnId = payload.turnId; + if (typeof agentId !== "string" || !agentId || agentId.length > 256 || + typeof turnId !== "string" || !turnId || turnId.length > 256) { + return Promise.reject(new Error("cannot record a turn without a bounded identity")); + } + // Admission happens before chaining onto the serialized queue. A slow + // timeline or publisher cannot retain unlimited hook payloads/contexts. + if (closed) return Promise.reject(new Error("recorder is closed")); + if (pendingCaptures >= MAX_JOBS) return Promise.reject(new Error("recording queue full; turn recording refused")); paseo = context.paseo; + const id = createHash("sha256").update(`${agentId}\0${turnId}`).digest("hex"); + const outcome = ["completed", "failed", "canceled"].includes(payload.outcome.kind) ? payload.outcome.kind : "unknown"; + pendingCaptures += 1; return serialize(async () => { - if (!["claude", "codex"].includes(payload.agent.provider)) return; - if (!payload.turnId) throw new Error("cannot record a turn without an identity"); - const id = createHash("sha256").update(`${payload.agent.id}\0${payload.turnId}`).digest("hex"); const names = await files(); const file = path.join(directory, `${id}.json`); if (!names.includes(`${id}.json`)) { @@ -149,10 +181,10 @@ export function createRecorder({ directory, channel, send = publish, retryMs = 1 if (names.length >= MAX_JOBS || sizes.reduce((sum, info) => sum + info.size, 0) >= MAX_BYTES - MAX_RECORD_BYTES) { throw new Error("recording spool full; turn recording refused"); } - await write(file, { id, agentId: payload.agent.id, turnId: payload.turnId, outcome: payload.outcome.kind }); + await write(file, { id, agentId, turnId, outcome }); } schedule(0); - }); + }).finally(() => { pendingCaptures -= 1; }); }, flush, async close() { closed = true; clearTimeout(timer); await queue; }, diff --git a/voltage/images/paseo-daemon/test-recorder.mjs b/voltage/images/paseo-daemon/test-recorder.mjs index 23c2411a96c..39eaf6cddfe 100644 --- a/voltage/images/paseo-daemon/test-recorder.mjs +++ b/voltage/images/paseo-daemon/test-recorder.mjs @@ -63,5 +63,75 @@ try { await assert.rejects(recorder.capture({ ...payload, turnId: "new-turn" }, context), /spool full/); assert.equal((await readdir(spool)).length, 256); await recorder.close(); + // A blocked timeline cannot hide an unbounded promise queue behind the + // on-disk spool cap. The 257th pending capture fails before the lookup ends. + await rm(spool, { recursive: true, force: true }); + let release; + let started; + const began = new Promise((resolve) => { started = resolve; }); + const blocked = new Promise((resolve) => { release = resolve; }); + let first = true; + const slow = { paseo: { agents: { ref: () => ({ timeline: { refetch: async (options) => { + assert.equal(options.timeout, 5000, "each upstream timeline request is bounded"); + if (first) { first = false; started(); await blocked; } + return { entries, hasOlder: false }; + } } }) } } }; + recorder = createRecorder({ ...options, send: async () => {} }); + await recorder.capture(payload, slow); + const flushing = recorder.flush(); + await began; + const queued = Array.from({ length: 256 }, (_, index) => recorder.capture({ ...payload, turnId: `queued-${index}` }, slow)); + let deadline; + try { + await assert.rejects(Promise.race([ + recorder.capture({ ...payload, turnId: "overflow" }, slow), + new Promise((resolve) => { deadline = setTimeout(resolve, 500); }), + ]), /queue full/); + assert.equal((await readdir(spool)).length, 1, "existing durable intent remains during overload"); + } finally { clearTimeout(deadline); release(); } + await flushing; + await Promise.all(queued); + await recorder.close(); + assert.ok((await readdir(spool)).length <= 256); + // Agent-open notifications coalesce with a blocked flush instead of + // enqueueing immediate retries that bypass the configured backoff. + await rm(spool, { recursive: true, force: true }); + let releaseRetry; + let notifyStarted; + const retryStarted = new Promise((resolve) => { notifyStarted = resolve; }); + const retryBlocked = new Promise((resolve) => { releaseRetry = resolve; }); + let sends = 0; + const retryContext = { paseo: { agents: { ref: () => ({ timeline: { refetch: async () => { + notifyStarted(); await retryBlocked; return { entries, hasOlder: false }; + } } }) } } }; + recorder = createRecorder({ ...options, send: async () => { sends += 1; throw new Error("offline"); } }); + await recorder.capture(payload, retryContext); + const retryFlush = recorder.flush(); + await retryStarted; + for (let index = 0; index < 40; index++) { + recorder.observe(retryContext); + await new Promise((resolve) => setTimeout(resolve, 0)); + } + releaseRetry(); await retryFlush; + await new Promise((resolve) => setTimeout(resolve, 10)); + assert.equal(sends, 1, "agent-open burst must not bypass retry backoff"); + assert.equal((await readdir(spool)).length, 1); + await recorder.close(); + + // Byte budgets hold for multilingual prompts, answers and tool names. + await rm(spool, { recursive: true, force: true }); + const unicodeEntries = [ + { turnId: "turn-1", item: { type: "user_message", text: "你好世界".repeat(2000) } }, + ...Array.from({ length: 50 }, () => ({ turnId: "turn-1", item: { type: "tool_call", name: "工具".repeat(120), status: "completed" } })), + { turnId: "turn-1", item: { type: "assistant_message", text: "回答結果".repeat(2000) } }, + ]; + const unicodeContext = { paseo: { agents: { ref: () => ({ timeline: { refetch: async () => ({ entries: unicodeEntries, hasOlder: false }) } }) } } }; + const unicodeSent = []; + recorder = createRecorder({ ...options, send: async (content) => { unicodeSent.push(content); } }); + await recorder.capture(payload, unicodeContext); await recorder.flush(); await recorder.close(); + assert.equal(unicodeSent.length, 1, "oversized multilingual turns must not retry forever"); + assert.ok(Buffer.byteLength(unicodeSent[0]) <= 24000); + assert.ok(!unicodeSent[0].includes("\ufffd"), "UTF-8 truncation preserves whole characters"); + assert.equal((await readdir(spool)).length, 0); console.log("PASS: recorder native-only filtering, redaction, turn fencing, journal retries, restart, acknowledgement and capacity refusal"); } finally { await recorder?.close(); await rm(root, { recursive: true, force: true }); } From 503eb200603101a9d88326e7c0354c4d81e869ed Mon Sep 17 00:00:00 2001 From: Tee8z Date: Tue, 10 Nov 2026 14:43:38 -0500 Subject: [PATCH 3/3] test(paseo): permit CI container access to provider stubs Signed-off-by: Tee8z --- voltage/images/paseo-daemon/test-image.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/voltage/images/paseo-daemon/test-image.sh b/voltage/images/paseo-daemon/test-image.sh index a935e331d77..e79fb5652b6 100644 --- a/voltage/images/paseo-daemon/test-image.sh +++ b/voltage/images/paseo-daemon/test-image.sh @@ -16,6 +16,9 @@ fi NAME="paseo-daemon-test-$$" containers=("$NAME" "$NAME-pa-off" "$NAME-optional") stubs=$(mktemp -d) +# GitHub runners and the daemon use different UIDs. The read-only bind mount +# must be traversable by the container rather than inheriting mktemp's 0700. +chmod 0755 "$stubs" cleanup() { docker rm -f "${containers[@]}" >/dev/null 2>&1 || true rm -rf "$stubs" @@ -152,6 +155,12 @@ env >/tmp/buzz.env exit 1 STUB chmod 0755 "$stubs/claude" "$stubs/buzz" +# Exercise a different UID even on developer machines where the daemon and +# host happen to share UID 1000. Without directory traversal the real provider +# would fall back to the image binary instead of these launch probes. +docker run --rm --network none --user 65534:65534 --entrypoint sh \ + -v "$stubs:/tmp/stubs:ro" "$IMAGE" \ + -c 'test -x /tmp/stubs/buzz && test -x /tmp/stubs/claude' || fail "provider stubs are inaccessible to another UID" STUB_PATH=/tmp/stubs:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin # Startup with the hardened flags.