diff --git a/.buildkite/pipeline.yml b/.buildkite/pipeline.yml index f7f9fc5..8245e7b 100644 --- a/.buildkite/pipeline.yml +++ b/.buildkite/pipeline.yml @@ -8,8 +8,10 @@ # # Secrets (Buildkite cluster → Secrets, org nandi / Default cluster): # - NIXBUILD_TOKEN (preferred) — soft-loaded; skip artifacts if missing -# - OPENBAO_TOKEN (optional) — fetch-openbao-env.sh → NIXBUILD_TOKEN +# - OPENBAO_TOKEN (optional) — fetch-openbao-env.sh → NIXBUILD_TOKEN / radicle # - CURSOR_API_KEY (issue agent) — Cursor CLI for Radicle issue → patch +# - RADICLE_SECRET_KEY (issue agent) — dedicated CI OpenSSH private key PEM +# - RADICLE_PUBLIC_KEY / RAD_PASSPHRASE (optional) — derived / empty OK # - BUILDKITE_API_TOKEN (optional) — poll/trigger scripts for new issues # UI: https://buildkite.com/organizations/nandi/clusters → Default cluster → Secrets # Pipeline: https://buildkite.com/nandi/sleek @@ -38,7 +40,9 @@ # CLI: ./scripts/buildkite/dispatch-poll.sh # Manual one issue: ./scripts/buildkite/trigger-issue-build.sh # (or: ./scripts/buildkite/dispatch-poll.sh --issue ) -# Requires cluster secret CURSOR_API_KEY (and rad identity on the agent to push patches). +# Requires CURSOR_API_KEY + RADICLE_SECRET_KEY (bootstrap installs rad, writes +# $RAD_HOME/keys, starts node, rad init --existing so git push rad works from +# the Garden HTTPS checkout). Agents also need TCP to garden:58019 for announce. agents: queue: auto diff --git a/AGENTS.md b/AGENTS.md index c514b81..5cf9033 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -94,11 +94,16 @@ Org `nandi`, Default cluster, hosted queue `auto`. Reference pipeline: Radicle CI adapter) API token is `BUILDKITE_API_KEY` in OpenBao (same KV). Agent skill: -`.cursor/skills/configure-buildkite/`. Radicle signing keys for publishing -patches live under OpenBao `secret/data/radicle`. +`.cursor/skills/configure-buildkite/`. Radicle **CI** signing keys for +publishing patches live under OpenBao `secret/data/radicle` (fields +`RADICLE_SECRET_KEY`, optional `RADICLE_PUBLIC_KEY` / `RAD_PASSPHRASE`) and/or +matching Buildkite cluster secrets — loaded by +`scripts/buildkite/bootstrap.sh` for the issue→agent step. Use a dedicated CI +identity (not a personal DID); never commit key material. Cluster secrets (soft-loaded; artifacts skip if missing): `NIXBUILD_TOKEN` -and/or `OPENBAO_TOKEN`. Scope `NIXBUILD_TOKEN` to `pipeline_slug: sleek` (and +and/or `OPENBAO_TOKEN`. Issue agent also needs `CURSOR_API_KEY` + +`RADICLE_SECRET_KEY`. Scope `NIXBUILD_TOKEN` to `pipeline_slug: sleek` (and `baogui-aopjch`). Helper: `scripts/ci-nixbuild.sh`. Check materializes flake.lock– pinned `vidya`/`freeq` via `scripts/sync-flake-path-deps.sh` and runs `cargo` inside `nix develop` (mold + libs from the flake, not apt). diff --git a/scripts/buildkite/bootstrap.sh b/scripts/buildkite/bootstrap.sh index 1309c20..ea86405 100755 --- a/scripts/buildkite/bootstrap.sh +++ b/scripts/buildkite/bootstrap.sh @@ -1,5 +1,16 @@ #!/usr/bin/env bash -# Install cursor-agent (if missing) and build Radicle MCP for CI runs. +# Install cursor-agent (if missing), materialize Radicle CI identity, and +# (optionally) build Radicle MCP for issue→agent runs. +# +# Cluster secrets (Buildkite → Default cluster → Secrets; soft-loaded): +# CURSOR_API_KEY — required for agent +# RADICLE_SECRET_KEY — OpenSSH private key PEM for a *dedicated* CI identity +# RADICLE_PUBLIC_KEY — optional; derived via ssh-keygen -y if missing +# RAD_PASSPHRASE — optional; empty OK for empty-passphrase CI keys +# OPENBAO_TOKEN — optional; can fetch the above from secret/data/radicle +# +# OpenBao field names under secret/data/radicle match the env names above. +# Never commit key material. Prefer a dedicated CI DID (not a personal identity). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -7,7 +18,31 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" source "$SCRIPT_DIR/lib.sh" REPO_ROOT="$(bk_repo_root)" -export PATH="${HOME}/.cursor/bin:${HOME}/.local/bin:/usr/local/bin:${PATH:-}" +export PATH="${HOME}/.local/bin:/usr/local/bin:${PATH:-}" + +# Sleek RID (Garden HTTPS checkouts lack a rad:// remote until we link them). +RADICLE_RID="${RADICLE_RID:-rad:z9mjPzpVK472QXaaP1picc5U9xBR}" +RAD_HOME="${RAD_HOME:-${HOME}/.radicle}" +export RAD_HOME +export PATH="${RAD_HOME}/bin:${PATH}" + +# Garden seed (from local preferredSeeds / AGENTS Garden host). +RADICLE_SEED="${RADICLE_SEED:-z6MknYm3iSpuY5hLCH93K5Ls5KG7cBK4fQwybqcHzxDsT2jU@nandi.radicle.garden:58019}" + +bk_soft_secret() { + local name=$1 value="" + if [[ -n "${!name:-}" ]]; then + return 0 + fi + if command -v buildkite-agent >/dev/null 2>&1; then + value="$(buildkite-agent secret get "$name" 2>/dev/null || true)" + if [[ -n "$value" ]]; then + # printf -v keeps multiline PEMs intact (unlike export name=$value). + printf -v "$name" '%s' "$value" + export "$name" + fi + fi +} install_cursor_agent() { if command -v cursor-agent >/dev/null 2>&1; then @@ -56,11 +91,7 @@ build_radicle_mcp() { verify_auth() { local cmd cmd="$(cursor_agent_cmd)" - if [[ -z "${CURSOR_API_KEY:-}" ]] && command -v buildkite-agent >/dev/null 2>&1; then - if key="$(buildkite-agent secret get CURSOR_API_KEY 2>/dev/null || true)"; then - [[ -n "$key" ]] && export CURSOR_API_KEY="$key" - fi - fi + bk_soft_secret CURSOR_API_KEY if [[ -z "${CURSOR_API_KEY:-}" ]]; then bk_die "CURSOR_API_KEY is not set — add it as a Buildkite cluster secret" fi @@ -69,8 +100,192 @@ verify_auth() { fi } +install_radicle_cli() { + if command -v rad >/dev/null 2>&1 && command -v git-remote-rad >/dev/null 2>&1; then + echo "[bootstrap] rad + git-remote-rad already on PATH" + return 0 + fi + echo "[bootstrap] installing Radicle CLI into ${RAD_HOME}..." + curl -fsSL https://radicle.xyz/install | sh -s -- --no-modify-path --prefix="$RAD_HOME" + export PATH="${RAD_HOME}/bin:${PATH}" + command -v rad >/dev/null 2>&1 || bk_die "rad missing after install" + command -v git-remote-rad >/dev/null 2>&1 || bk_die "git-remote-rad missing after install" +} + +load_radicle_secrets() { + bk_soft_secret OPENBAO_TOKEN + bk_soft_secret RADICLE_SECRET_KEY + bk_soft_secret RADICLE_PUBLIC_KEY + bk_soft_secret RAD_PASSPHRASE + + # Optional OpenBao fallback (field names = env names under secret/data/radicle). + # Only require RADICLE_SECRET_KEY; public key / passphrase are optional. + if [[ -z "${RADICLE_SECRET_KEY:-}" && -n "${OPENBAO_TOKEN:-}" ]]; then + local root fetch exports + root="$(cd "$SCRIPT_DIR/../.." && pwd)" + fetch="$root/scripts/fetch-openbao-env.sh" + if [[ -x "$fetch" ]]; then + echo "[bootstrap] loading RADICLE_SECRET_KEY from OpenBao secret/data/radicle" + if exports="$( + OPENBAO_SECRET_PATH=secret/data/radicle \ + OPENBAO_EXTRA_PATHS= \ + "$fetch" --export --keys RADICLE_SECRET_KEY 2>/dev/null + )"; then + eval "$exports" + else + echo "[bootstrap] warn: OpenBao secret/data/radicle missing RADICLE_SECRET_KEY" >&2 + fi + # Best-effort optional fields (ignore failure if absent). + if exports="$( + OPENBAO_SECRET_PATH=secret/data/radicle \ + OPENBAO_EXTRA_PATHS= \ + "$fetch" --export --keys RADICLE_PUBLIC_KEY 2>/dev/null + )"; then + eval "$exports" + fi + if exports="$( + OPENBAO_SECRET_PATH=secret/data/radicle \ + OPENBAO_EXTRA_PATHS= \ + "$fetch" --export --keys RAD_PASSPHRASE 2>/dev/null + )"; then + eval "$exports" + fi + fi + fi +} + +write_radicle_config() { + mkdir -p "$RAD_HOME" + if [[ -f "$RAD_HOME/config.json" ]]; then + return 0 + fi + cat >"$RAD_HOME/config.json" <"$key_path" + else + if ! printf '%s' "$RADICLE_SECRET_KEY" | base64 -d >"$key_path" 2>/dev/null; then + bk_die "RADICLE_SECRET_KEY is neither OpenSSH PEM nor base64" + fi + if ! head -1 "$key_path" | grep -q 'BEGIN.*PRIVATE KEY'; then + bk_die "RADICLE_SECRET_KEY base64 did not decode to an OpenSSH private key" + fi + fi + chmod 600 "$key_path" + + if [[ -n "${RADICLE_PUBLIC_KEY:-}" ]]; then + printf '%s\n' "$RADICLE_PUBLIC_KEY" >"$pub_path" + else + # Empty passphrase is fine for dedicated CI keys (seeder-style). + if ! ssh-keygen -y -P "${RAD_PASSPHRASE:-}" -f "$key_path" >"$pub_path" 2>/dev/null; then + bk_die "failed to derive public key — set RADICLE_PUBLIC_KEY or fix RAD_PASSPHRASE" + fi + fi + chmod 644 "$pub_path" + echo "[bootstrap] materialized Radicle identity keys (alias config: sleek-ci)" +} + +# Export empty RAD_PASSPHRASE when unset so git-remote-rad uses MemorySigner +# instead of requiring ssh-agent (ephemeral hosted agents rarely have one ready). +ensure_rad_passphrase_env() { + if [[ -z "${RAD_PASSPHRASE+x}" ]]; then + export RAD_PASSPHRASE="" + else + export RAD_PASSPHRASE + fi +} + +start_rad_node() { + if rad node status >/dev/null 2>&1; then + echo "[bootstrap] rad node already running" + else + echo "[bootstrap] starting rad node..." + rad node start >/dev/null + fi + # Best-effort connect to Garden so patch announce can reach the seed. + rad node connect "$RADICLE_SEED" >/dev/null 2>&1 || true +} + +# Garden HTTPS checkouts only have `origin` → https://…garden….git. +# Link them into local storage + add `rad` remote so `git push rad` works. +ensure_rad_remote() { + local root + root="$(bk_repo_root)" + if git -C "$root" remote get-url rad >/dev/null 2>&1; then + echo "[bootstrap] git remote 'rad' already configured" + return 0 + fi + echo "[bootstrap] linking Garden checkout via rad init --existing ${RADICLE_RID}" + ( + cd "$root" + rad init --existing "$RADICLE_RID" \ + --name sleek \ + --default-branch main \ + --no-confirm \ + --public + ) + git -C "$root" remote get-url rad >/dev/null 2>&1 \ + || bk_die "rad remote still missing after rad init --existing" +} + +setup_radicle_identity() { + # Soft-skip when this bootstrap is reused outside the issue-agent path and + # no identity secrets are configured yet. + load_radicle_secrets + if [[ -z "${RADICLE_SECRET_KEY:-}" && ! -f "$RAD_HOME/keys/radicle" ]]; then + if [[ "${RADICLE_REQUIRE_IDENTITY:-1}" == "1" ]]; then + bk_die "no Radicle CI identity — set RADICLE_SECRET_KEY (Buildkite) or keys under \$RAD_HOME" + fi + echo "[bootstrap] warn: skipping Radicle identity (RADICLE_REQUIRE_IDENTITY=0)" + return 0 + fi + + install_radicle_cli + write_radicle_config + materialize_radicle_keys + ensure_rad_passphrase_env + start_rad_node + ensure_rad_remote + + echo "[bootstrap] Radicle identity ready: $(rad self --alias 2>/dev/null || echo '?') $(rad self --did 2>/dev/null || true)" +} + install_cursor_agent build_radicle_mcp verify_auth +setup_radicle_identity -echo "[bootstrap] ready (repo=$REPO_ROOT)" +echo "[bootstrap] ready (repo=$REPO_ROOT rad_home=$RAD_HOME)" diff --git a/scripts/buildkite/create-ci-radicle-identity.sh b/scripts/buildkite/create-ci-radicle-identity.sh new file mode 100755 index 0000000..09f6ec5 --- /dev/null +++ b/scripts/buildkite/create-ci-radicle-identity.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# Create a *dedicated* Radicle CI identity and print upload instructions. +# Does not touch your personal ~/.radicle. Never commit the private key. +# +# Usage: +# ./scripts/buildkite/create-ci-radicle-identity.sh +# ./scripts/buildkite/create-ci-radicle-identity.sh --alias sleek-ci +# +# Then store the printed fields as Buildkite cluster secrets and/or: +# OPENBAO_SECRET_PATH=secret/data/radicle ./scripts/openbao-put-key.sh RADICLE_SECRET_KEY --value "$(cat …)" +set -euo pipefail + +ALIAS="sleek-ci" +OUT_DIR="" +while [[ $# -gt 0 ]]; do + case "$1" in + --alias) ALIAS="$2"; shift 2 ;; + --out) OUT_DIR="$2"; shift 2 ;; + -h|--help) + sed -n '2,12p' "$0" + exit 0 + ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +if ! command -v rad >/dev/null 2>&1; then + echo "rad not on PATH — install with: curl -fsSL https://radicle.xyz/install | sh" >&2 + exit 1 +fi + +OUT_DIR="${OUT_DIR:-$(mktemp -d /tmp/sleek-ci-radicle.XXXXXX)}" +mkdir -p "$OUT_DIR" +export RAD_HOME="$OUT_DIR" + +# Empty passphrase (seeder-style). Ephemeral agents use RAD_PASSPHRASE="" + MemorySigner. +printf '\n' | rad auth --alias "$ALIAS" --stdin >/dev/null + +DID="$(rad self --did)" +PUB="$(cat "$RAD_HOME/keys/radicle.pub")" + +umask 077 +cp "$RAD_HOME/keys/radicle" "$OUT_DIR/RADICLE_SECRET_KEY.pem" +cp "$RAD_HOME/keys/radicle.pub" "$OUT_DIR/RADICLE_PUBLIC_KEY.pub" +chmod 600 "$OUT_DIR/RADICLE_SECRET_KEY.pem" + +cat <