diff --git a/.trinity/seals/queen_QueenKeyCredit.json b/.trinity/seals/queen_QueenKeyCredit.json new file mode 100644 index 0000000000..11228b4223 --- /dev/null +++ b/.trinity/seals/queen_QueenKeyCredit.json @@ -0,0 +1,45 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", + "closure": [ + { + "spec": "specs/queen/dispatch.t27", + "spec_hash": "sha256:e71997bc00e5e3c6fffc1d92d0ea1e73c09326215533f7bdec6a27ed9fffd7fe" + }, + { + "spec": "specs/trinet/node-work-credit.t27", + "spec_hash": "sha256:7cafb68c406c379ddde0c4e655c15455c5fd84988733a6d6b660117eff4caea7" + }, + { + "spec": "specs/verified/payout.t27", + "spec_hash": "sha256:7b5e8bca72e68efd2050b469bb784d68b7ce5dd15907a5f44f0f132475fead87" + }, + { + "spec": "specs/verified/signed_receipt.t27", + "spec_hash": "sha256:20728477c97dc10f66c47391e1418aff63769660c180b3912765a86f322fdb61" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:9dde6079e18ce5eefc0f0a2cc4f92c52cd99258d9e3a2623af0efb51c6e14531", + "gen_hash_rust": "sha256:9fe70e71a096a45d32f50ec71257cc06a55858f78bab104ae95561947924330f", + "gen_hash_verilog": "sha256:b82dd39302f24d8ec334662098a549963c2179b5a7ba62ad876b3bbe558e7cdd", + "gen_hash_zig": "sha256:514f10b88fb9d3ed54a3770a0681aec20605a978f567509af30ac02eeb402ddc", + "module": "QueenKeyCredit", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T18:16:27Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:9f031173e2757ee59dba0060a05754eb3257c7289bbdbc49e718d134910429bc", + "spec_path": "specs/queen/key_credit.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 12, + "total": 12, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/.trinity/seals/queen_QueenKeyVault.json b/.trinity/seals/queen_QueenKeyVault.json new file mode 100644 index 0000000000..354aafbccb --- /dev/null +++ b/.trinity/seals/queen_QueenKeyVault.json @@ -0,0 +1,37 @@ +{ + "built_by": "t27c-bootstrap@0.5.2+9448ab4be", + "closure": [ + { + "spec": "specs/queen/key_failover.t27", + "spec_hash": "sha256:822367357fc6b487c1c7fb7ac8369165e6a7e6636fd9b0dcafec29e9a6274301" + }, + { + "spec": "specs/queen/reviewer_sizing.t27", + "spec_hash": "sha256:7a37c374021c2143bcccfa22c9e6e6d69bb25d21a06ef33442a26902a51d0a8e" + } + ], + "config": "gen=c,rust,verilog,zig;tests=zig", + "gen_hash_c": "sha256:e2ecd4302208c532e417ad207206db2140aeffbbad282add8f5249bc526c478b", + "gen_hash_rust": "sha256:94f44d51c436fbde5bd5b1385da8b16faaaeb9ddc0e650f40cc81b4478c554eb", + "gen_hash_verilog": "sha256:d10fc885c476a90c7c44ca887b9567e5f60a70a25fdf2d240205cc93aa9e10a4", + "gen_hash_zig": "sha256:28242e4d9504706f2f1cec32384a00051f056d8da28c81f1cc34e7329fa6e3fe", + "module": "QueenKeyVault", + "ring": 12, + "seal_schema": 2, + "sealed_at": "2026-10-18T17:29:18Z", + "sealed_by": "t27c-bootstrap@0.5.2", + "spec_hash": "sha256:caa5c0913648ec6eb545c0544fe0d51e383c42f5b4617ae25db6f542f2fa4097", + "spec_path": "specs/queen/key_vault.t27", + "tests": { + "failed": 0, + "failing": [], + "forced": false, + "passed": 8, + "total": 8, + "vacuous": 0 + }, + "toolchain": { + "test_runner": "sha256:8512b80da533b79289110fca4a65f3336abd7a059ca4b72ea595d5e33ff8ad10", + "zig": "0.16.0" + } +} \ No newline at end of file diff --git a/contrib/portable-claude-setup/README.md b/contrib/portable-claude-setup/README.md index d2f8796951..59832d9de3 100644 --- a/contrib/portable-claude-setup/README.md +++ b/contrib/portable-claude-setup/README.md @@ -10,8 +10,6 @@ Dotfiles-style bundle for [Claude Code](https://www.anthropic.com/claude-code): | `env.example` | Variables to copy into `~/.claude/.env` | | `scripts/sync-settings-from-env.sh` | Merges `.env` into `settings.json` (`env.*` and optional MCP commands) | | `scripts/apply-anthropic-token-from-env.sh` | Legacy name; calls `sync-settings-from-env.sh` | -| `scripts/rotate-keys.sh` | Rotate numbered API keys (round-robin / random / health-check) | -| `scripts/check-key-health.sh` | Test all configured keys and report status | ## Clone from GitHub @@ -46,94 +44,17 @@ cd portable-claude-setup Set `ZAI_USE=2` and `ZAI_KEY_2=...`; otherwise the first non-empty `ZAI_KEY_1`, `ZAI_KEY_2`, … (numeric order) is used. -## Key Rotation +## Key rotation -When you have multiple API keys for the same provider (e.g. several Railway accounts), the rotation scripts let you cycle through them automatically. - -### Setup - -Add numbered keys to `~/.claude/.env`: - -```bash -# Railway accounts -RAILWAY_TOKEN_1=railway-token-aaa -RAILWAY_TOKEN_2=railway-token-bbb -RAILWAY_TOKEN_3=railway-token-ccc - -# GitHub tokens -GH_TOKEN_1=ghp_aaa -GH_TOKEN_2=ghp_bbb - -# OpenAI keys -OPENAI_KEY_1=sk-aaa -OPENAI_KEY_2=sk-bbb - -# Z.AI / Anthropic (existing ZAI_KEY_N vars are already supported) -ZAI_KEY_1=sk-ant-aaa -ZAI_KEY_2=sk-ant-bbb -``` - -### Rotating keys - -```bash -# Rotate all families (round-robin, default) -bash scripts/rotate-keys.sh - -# Rotate a specific family -bash scripts/rotate-keys.sh --family railway - -# Pick a random key -bash scripts/rotate-keys.sh --random - -# Test each key, use the first healthy one -bash scripts/rotate-keys.sh --health-check - -# View current state -bash scripts/rotate-keys.sh --status -``` - -Rotation state is stored in `~/.claude/.rotation-state.json`. After each rotation, `sync-settings-from-env.sh` runs automatically to update `settings.json`. - -### Health check - -Test all configured keys without rotating: - -```bash -bash scripts/check-key-health.sh -``` - -### Cron integration - -Auto-rotate every 30 minutes with a health check: - -```cron -*/30 * * * * bash ~/portable-claude-setup/scripts/rotate-keys.sh --health-check -``` - -Or check health and alert (no rotation): - -```cron -0 */6 * * * bash ~/portable-claude-setup/scripts/check-key-health.sh || echo "Key failure" | mail -s "API key down" you@example.com -``` +There is none here, on purpose. Owner, 2026-10-18: every API key lives in one place, the Queen's +store, and is shared out to everyone who uses it (`specs/queen/key_vault.t27` in gHashTag/t27, +#10564). A numbered pool of private keys rotated on one machine is a second home for keys that +nobody else can see or share, so the rotation scripts that lived here were deleted. A consumer +that needs a model leases from the Queen (for the reviewer bees: `BEE_QUEEN_LEASE_URL` and +`BEE_QUEEN_LEASE_TOKEN`); keys you hold go into her store through the contributor-key flow. ## Security - **Do not push** your entire `~/.claude` directory: it may include `history.jsonl`, plugin caches, and tracked `settings.json` with secrets. - This repo should contain **only** templates and scripts—no `.env` or real tokens. -## OpenCode key rotation - -Analogous to the Claude Code rotation above, `rotate-opencode-keys.sh` manages ZAI keys in `~/.local/share/opencode/auth.json`: - -```bash -# Rotate ZAI keys (reads ZAI_KEY_1..N from ~/.claude/.env) -bash scripts/rotate-opencode-keys.sh - -# View current state -bash scripts/rotate-opencode-keys.sh --status - -# Random key selection -bash scripts/rotate-opencode-keys.sh --random -``` - -After rotation, `anthropic` and `zai-coding-plan` providers in `auth.json` point to the active key. All keys are also stored as `zai-1`..`zai-N` for manual provider selection. diff --git a/contrib/portable-claude-setup/scripts/check-key-health.sh b/contrib/portable-claude-setup/scripts/check-key-health.sh deleted file mode 100755 index 0d5db78fe8..0000000000 --- a/contrib/portable-claude-setup/scripts/check-key-health.sh +++ /dev/null @@ -1,162 +0,0 @@ -#!/bin/bash -# Test all configured API keys and report their health status -# -# Reads KEY_FAMILY_N variables from ~/.claude/.env and probes each -# provider's API. Never prints actual key values — only indices and -# human-readable status. -# -# Usage: -# check-key-health.sh [--env FILE] - -set -euo pipefail - -ENV_FILE="${HOME}/.claude/.env" - -while [[ $# -gt 0 ]]; do - case "$1" in - --env) ENV_FILE="$2"; shift 2 ;; - *) echo "Unknown option: $1"; exit 1 ;; - esac -done - -if [ ! -f "$ENV_FILE" ]; then - echo "❌ Missing file: $ENV_FILE" - exit 1 -fi - -set -a -# shellcheck disable=SC1090 -source "$ENV_FILE" -set +a - -PASS=0 -FAIL=0 -SKIP=0 - -result() { - local label="$1" status="$2" detail="${3:-}" - if [ "$status" = "ok" ]; then - PASS=$((PASS + 1)) - if [ -n "$detail" ]; then - echo " $label: ✓ valid ($detail)" - else - echo " $label: ✓ valid" - fi - elif [ "$status" = "skip" ]; then - SKIP=$((SKIP + 1)) - echo " $label: ○ skipped (no health-check endpoint)" - else - FAIL=$((FAIL + 1)) - if [ -n "$detail" ]; then - echo " $label: ✗ $detail" - else - echo " $label: ✗ failed" - fi - fi -} - -# ── ZAI / Anthropic keys ──────────────────────────────────────────── -echo "=== ZAI (Anthropic) ===" -i=1 -found=false -while true; do - vn="ZAI_KEY_${i}" - vv=$(printenv "$vn" 2>/dev/null || true) - [ -z "$vv" ] && break - found=true - # No reliable free health-check endpoint for Anthropic - result "$vn" "skip" - i=$((i + 1)) -done -if [ "$found" = false ]; then - echo " (none configured)" -fi - -# ── Railway tokens ─────────────────────────────────────────────────── -echo "" -echo "=== Railway ===" -i=1 -found=false -while true; do - vn="RAILWAY_TOKEN_${i}" - vv=$(printenv "$vn" 2>/dev/null || true) - [ -z "$vv" ] && break - found=true - resp=$(curl -s --max-time 10 \ - --request POST \ - --url "https://backboard.railway.com/graphql/v2" \ - --header "Authorization: Bearer $vv" \ - --header "Content-Type: application/json" \ - --data '{"query":"{ projects { edges { node { id name } } } }"}' 2>/dev/null || echo "") - if echo "$resp" | jq -e '.data.projects' >/dev/null 2>&1; then - # Extract first project name for display - proj=$(echo "$resp" | jq -r '.data.projects.edges[0].node.name // "unknown"') - result "$vn" "ok" "project: $proj" - else - err=$(echo "$resp" | jq -r '.errors[0].message // "unknown error"' 2>/dev/null || echo "request failed") - result "$vn" "fail" "$err" - fi - i=$((i + 1)) -done -if [ "$found" = false ]; then - echo " (none configured)" -fi - -# ── OpenAI keys ────────────────────────────────────────────────────── -echo "" -echo "=== OpenAI ===" -i=1 -found=false -while true; do - vn="OPENAI_KEY_${i}" - vv=$(printenv "$vn" 2>/dev/null || true) - [ -z "$vv" ] && break - found=true - resp=$(curl -s --max-time 10 \ - -H "Authorization: Bearer $vv" \ - "https://api.openai.com/v1/models" 2>/dev/null || echo "") - if echo "$resp" | jq -e '.data' >/dev/null 2>&1; then - result "$vn" "ok" - else - err=$(echo "$resp" | jq -r '.error.message // "unknown error"' 2>/dev/null || echo "request failed") - result "$vn" "fail" "$err" - fi - i=$((i + 1)) -done -if [ "$found" = false ]; then - echo " (none configured)" -fi - -# ── GitHub tokens ──────────────────────────────────────────────────── -echo "" -echo "=== GitHub ===" -i=1 -found=false -while true; do - vn="GH_TOKEN_${i}" - vv=$(printenv "$vn" 2>/dev/null || true) - [ -z "$vv" ] && break - found=true - resp=$(curl -s --max-time 10 \ - -H "Authorization: token $vv" \ - "https://api.github.com/user" 2>/dev/null || echo "") - if echo "$resp" | jq -e '.login' >/dev/null 2>&1; then - user=$(echo "$resp" | jq -r '.login') - result "$vn" "ok" "user: $user" - else - err=$(echo "$resp" | jq -r '.message // "unknown error"' 2>/dev/null || echo "request failed") - result "$vn" "fail" "$err" - fi - i=$((i + 1)) -done -if [ "$found" = false ]; then - echo " (none configured)" -fi - -# ── Summary ────────────────────────────────────────────────────────── -echo "" -echo "--- Summary: $PASS valid, $FAIL failed, $SKIP skipped ---" -if [ "$FAIL" -gt 0 ]; then - exit 1 -fi -exit 0 diff --git a/contrib/portable-claude-setup/scripts/rotate-keys.sh b/contrib/portable-claude-setup/scripts/rotate-keys.sh deleted file mode 100755 index 68b2acc226..0000000000 --- a/contrib/portable-claude-setup/scripts/rotate-keys.sh +++ /dev/null @@ -1,300 +0,0 @@ -#!/bin/bash -# Rotate API keys across numbered pools (round-robin, random, or health-check) -# -# Reads KEY_FAMILY_1, KEY_FAMILY_2, … from ~/.claude/.env and advances -# the active index tracked in ~/.claude/.rotation-state.json. -# After rotation, calls sync-settings-from-env.sh to update settings.json. -# -# Supported families: -# zai — ZAI_KEY_1 … ZAI_KEY_N (Anthropic / Z.AI tokens) -# railway — RAILWAY_TOKEN_1 … N (Railway API tokens) -# openai — OPENAI_KEY_1 … N (OpenAI keys) -# gh — GH_TOKEN_1 … N (GitHub tokens) -# -# Usage: -# rotate-keys.sh [OPTIONS] -# -# Options: -# --round-robin (default) advance to next key -# --random pick a random key -# --health-check test each key, use first working one -# --family NAME rotate only the given family (zai|railway|openai|gh) -# --status show current rotation state and exit -# --env FILE path to .env file (default: ~/.claude/.env) -# --settings FILE path to settings.json (default: ~/.claude/settings.json) - -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -ENV_FILE="${HOME}/.claude/.env" -SETTINGS="${HOME}/.claude/settings.json" -STATE_FILE="${HOME}/.claude/.rotation-state.json" -STRATEGY="round-robin" -FAMILY_FILTER="" -SHOW_STATUS=false - -# ── Parse arguments ────────────────────────────────────────────────── -while [[ $# -gt 0 ]]; do - case "$1" in - --round-robin) STRATEGY="round-robin"; shift ;; - --random) STRATEGY="random"; shift ;; - --health-check) STRATEGY="health-check"; shift ;; - --family) FAMILY_FILTER="$2"; shift 2 ;; - --status) SHOW_STATUS=true; shift ;; - --env) ENV_FILE="$2"; shift 2 ;; - --settings) SETTINGS="$2"; shift 2 ;; - *) echo "Unknown option: $1"; exit 1 ;; - esac -done - -# ── Helpers ────────────────────────────────────────────────────────── - -now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } - -ensure_state_file() { - if [ ! -f "$STATE_FILE" ]; then - echo '{}' > "$STATE_FILE" - fi -} - -read_state() { - jq -r ".[\"$1\"] // empty" "$STATE_FILE" -} - -# Count keys for a family (e.g. ZAI_KEY_1 … ZAI_KEY_N) -count_keys() { - local prefix="$1" - local count=0 - local i=1 - while true; do - local vn="${prefix}_${i}" - local vv - vv=$(printenv "$vn" 2>/dev/null || true) - if [ -z "$vv" ]; then - break - fi - count=$((count + 1)) - i=$((i + 1)) - done - echo "$count" -} - -# Map family name → env-var prefix -family_prefix() { - case "$1" in - zai) echo "ZAI_KEY" ;; - railway) echo "RAILWAY_TOKEN" ;; - openai) echo "OPENAI_KEY" ;; - gh) echo "GH_TOKEN" ;; - *) echo ""; return 1 ;; - esac -} - -# Map family → the env var sync-settings uses -family_active_var() { - case "$1" in - zai) echo "ZAI_USE" ;; - railway) echo "RAILWAY_ACTIVE_TOKEN" ;; - openai) echo "OPENAI_API_KEY" ;; - gh) echo "GH_TOKEN" ;; - *) echo "" ;; - esac -} - -mask_key() { - local key="$1" - local len=${#key} - if [ "$len" -le 8 ]; then - echo "****" - else - echo "${key:0:4}…${key: -4}" - fi -} - -# ── Health checks ──────────────────────────────────────────────────── - -check_railway_key() { - local token="$1" - local resp - resp=$(curl -s --max-time 10 \ - --request POST \ - --url "https://backboard.railway.com/graphql/v2" \ - --header "Authorization: Bearer $token" \ - --header "Content-Type: application/json" \ - --data '{"query":"{ projects { edges { node { id name } } } }"}' 2>/dev/null || echo "") - if echo "$resp" | jq -e '.data.projects' >/dev/null 2>&1; then - return 0 - fi - return 1 -} - -check_gh_key() { - local token="$1" - local resp - resp=$(curl -s --max-time 10 \ - -H "Authorization: token $token" \ - "https://api.github.com/user" 2>/dev/null || echo "") - if echo "$resp" | jq -e '.login' >/dev/null 2>&1; then - return 0 - fi - return 1 -} - -check_key() { - local family="$1" - local token="$2" - case "$family" in - railway) check_railway_key "$token" ;; - gh) check_gh_key "$token" ;; - *) return 0 ;; # no health check available — assume valid - esac -} - -# ── Source .env ────────────────────────────────────────────────────── -if [ ! -f "$ENV_FILE" ]; then - echo "❌ Missing file: $ENV_FILE" - exit 1 -fi - -set -a -# shellcheck disable=SC1090 -source "$ENV_FILE" -set +a - -ensure_state_file - -# ── Status display ─────────────────────────────────────────────────── -if [ "$SHOW_STATUS" = true ]; then - echo "=== Key Rotation State ===" - echo "State file: $STATE_FILE" - echo "" - for fam in zai railway openai gh; do - prefix=$(family_prefix "$fam") - total=$(count_keys "$prefix") - if [ "$total" -eq 0 ]; then - echo " $fam: no keys configured" - continue - fi - state_json=$(read_state "$fam") - if [ -n "$state_json" ]; then - cur_idx=$(echo "$state_json" | jq -r '.current_index') - last_rot=$(echo "$state_json" | jq -r '.last_rotated') - echo " $fam: index=$cur_idx/$total last_rotated=$last_rot" - else - echo " $fam: $total keys, no rotation state yet (will start at 1)" - fi - done - exit 0 -fi - -# ── Build family list ──────────────────────────────────────────────── -FAMILIES="zai railway openai gh" -if [ -n "$FAMILY_FILTER" ]; then - if ! family_prefix "$FAMILY_FILTER" >/dev/null 2>&1; then - echo "❌ Unknown family: $FAMILY_FILTER (valid: zai, railway, openai, gh)" - exit 1 - fi - FAMILIES="$FAMILY_FILTER" -fi - -ROTATED_ANY=false - -# ── Rotate each family ────────────────────────────────────────────── -for fam in $FAMILIES; do - prefix=$(family_prefix "$fam") - total=$(count_keys "$prefix") - - if [ "$total" -eq 0 ]; then - continue - fi - if [ "$total" -eq 1 ]; then - # Single key — ensure state is set but nothing to rotate - jq --arg f "$fam" --arg now "$(now_iso)" \ - '.[$f] = {"current_index": 1, "last_rotated": $now, "total_keys": 1}' \ - "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" - continue - fi - - # Current index (default: 1) - state_json=$(read_state "$fam") - if [ -n "$state_json" ]; then - cur_idx=$(echo "$state_json" | jq -r '.current_index') - else - cur_idx=1 - fi - old_idx=$cur_idx - - case "$STRATEGY" in - round-robin) - new_idx=$(( (cur_idx % total) + 1 )) - ;; - random) - new_idx=$(( (RANDOM % total) + 1 )) - # Avoid picking the same key if possible - if [ "$total" -gt 1 ] && [ "$new_idx" -eq "$cur_idx" ]; then - new_idx=$(( (new_idx % total) + 1 )) - fi - ;; - health-check) - new_idx="" - # Start from the next key after current - for offset in $(seq 1 "$total"); do - candidate=$(( ((cur_idx - 1 + offset) % total) + 1 )) - vn="${prefix}_${candidate}" - vv=$(printenv "$vn" 2>/dev/null || true) - if [ -n "$vv" ] && check_key "$fam" "$vv"; then - new_idx=$candidate - break - else - echo " ⚠ $fam: key #$candidate failed health check" - fi - done - if [ -z "$new_idx" ]; then - echo " ❌ $fam: all $total keys failed health check — keeping index $cur_idx" - continue - fi - ;; - esac - - # Update state - jq --arg f "$fam" --argjson idx "$new_idx" --arg now "$(now_iso)" --argjson tot "$total" \ - '.[$f] = {"current_index": $idx, "last_rotated": $now, "total_keys": $tot}' \ - "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" - - echo "🔄 $fam: rotated index $old_idx → $new_idx (of $total keys) [$STRATEGY]" - - # Export the active key so sync can pick it up - case "$fam" in - zai) - # Set ZAI_USE so sync-settings uses the right key - export ZAI_USE="$new_idx" - ;; - railway) - vn="${prefix}_${new_idx}" - export RAILWAY_API_TOKEN - RAILWAY_API_TOKEN=$(printenv "$vn" 2>/dev/null || true) - ;; - openai) - vn="${prefix}_${new_idx}" - export OPENAI_API_KEY - OPENAI_API_KEY=$(printenv "$vn" 2>/dev/null || true) - ;; - gh) - vn="${prefix}_${new_idx}" - export GH_TOKEN - GH_TOKEN=$(printenv "$vn" 2>/dev/null || true) - ;; - esac - - ROTATED_ANY=true -done - -# ── Re-sync settings.json ─────────────────────────────────────────── -if [ "$ROTATED_ANY" = true ]; then - echo "" - echo "Syncing settings.json …" - bash "${SCRIPT_DIR}/sync-settings-from-env.sh" "$ENV_FILE" "$SETTINGS" -fi - -echo "" -echo "Done. Use --status to view current state." diff --git a/contrib/portable-claude-setup/scripts/rotate-opencode-keys.sh b/contrib/portable-claude-setup/scripts/rotate-opencode-keys.sh deleted file mode 100755 index 32e2e11c1c..0000000000 --- a/contrib/portable-claude-setup/scripts/rotate-opencode-keys.sh +++ /dev/null @@ -1,202 +0,0 @@ -#!/bin/bash -# Rotate ZAI API keys in opencode auth.json -# -# Reads ZAI_KEY_1..N from ~/.claude/.env (or --env FILE) and rotates -# the "anthropic" credential in ~/.local/share/opencode/auth.json. -# Also writes ZAI_KEY_N as separate providers (zai-plan-1..N) for -# model-level failover. -# -# Usage: -# rotate-opencode-keys.sh [OPTIONS] -# -# Options: -# --round-robin (default) advance to next key -# --random pick a random key -# --env FILE path to .env file (default: ~/.claude/.env) -# --auth FILE path to auth.json (default: ~/.local/share/opencode/auth.json) -# --status show current state and exit - -set -euo pipefail - -ENV_FILE="${HOME}/.claude/.env" -AUTH_FILE="${HOME}/.local/share/opencode/auth.json" -STATE_FILE="${HOME}/.claude/.opencode-rotation-state.json" -STRATEGY="round-robin" -SHOW_STATUS=false - -while [[ $# -gt 0 ]]; do - case "$1" in - --round-robin) STRATEGY="round-robin"; shift ;; - --random) STRATEGY="random"; shift ;; - --env) ENV_FILE="$2"; shift 2 ;; - --auth) AUTH_FILE="$2"; shift 2 ;; - --status) SHOW_STATUS=true; shift ;; - *) echo "Unknown option: $1"; exit 1 ;; - esac -done - -now_iso() { date -u +"%Y-%m-%dT%H:%M:%SZ"; } - -ensure_state_file() { - if [ ! -f "$STATE_FILE" ]; then - echo '{}' > "$STATE_FILE" - fi -} - -read_state() { - jq -r ".[\"$1\"] // empty" "$STATE_FILE" 2>/dev/null || echo "" -} - -mask_key() { - local key="$1" - local len=${#key} - if [ "$len" -le 8 ]; then - echo "****" - else - echo "${key:0:4}...${key: -4}" - fi -} - -count_keys() { - local count=0 - local i=1 - while true; do - local vn="ZAI_KEY_${i}" - local vv - vv=$(printenv "$vn" 2>/dev/null || true) - if [ -z "$vv" ]; then - break - fi - count=$((count + 1)) - i=$((i + 1)) - done - echo "$count" -} - -if [ ! -f "$ENV_FILE" ]; then - echo "Missing: $ENV_FILE" - echo "Create it: cp contrib/portable-claude-setup/env.example ~/.claude/.env" - exit 1 -fi - -set -a -# shellcheck disable=SC1090 -source "$ENV_FILE" -set +a - -ensure_state_file - -# Collect all ZAI keys -KEYS=() -i=1 -while true; do - vn="ZAI_KEY_${i}" - vv=$(printenv "$vn" 2>/dev/null || true) - if [ -z "$vv" ]; then break; fi - KEYS+=("$vv") - i=$((i + 1)) -done -total=${#KEYS[@]} - -if [ "$total" -eq 0 ]; then - echo "No ZAI_KEY_N found in $ENV_FILE" - exit 1 -fi - -# ── Status ──────────────────────────────────────────────────────────── -if [ "$SHOW_STATUS" = true ]; then - echo "=== OpenCode Key Rotation State ===" - echo "auth.json: $AUTH_FILE" - echo "env file: $ENV_FILE" - echo "" - echo "ZAI keys found: $total" - state_json=$(read_state "zai") - if [ -n "$state_json" ]; then - cur_idx=$(echo "$state_json" | jq -r '.current_index') - last_rot=$(echo "$state_json" | jq -r '.last_rotated') - echo "Current index: $cur_idx" - echo "Last rotated: $last_rot" - else - echo "No rotation state yet (will start at 1)" - fi - echo "" - echo "Keys:" - for j in $(seq 1 "$total"); do - masked=$(mask_key "${KEYS[$((j-1))]}") - echo " ZAI_KEY_$j = $masked" - done - exit 0 -fi - -# ── Determine new index ─────────────────────────────────────────────── -state_json=$(read_state "zai") -if [ -n "$state_json" ]; then - cur_idx=$(echo "$state_json" | jq -r '.current_index') -else - cur_idx=1 -fi - -case "$STRATEGY" in - round-robin) - new_idx=$(( (cur_idx % total) + 1 )) - ;; - random) - new_idx=$(( (RANDOM % total) + 1 )) - if [ "$total" -gt 1 ] && [ "$new_idx" -eq "$cur_idx" ]; then - new_idx=$(( (new_idx % total) + 1 )) - fi - ;; -esac - -# ── Update auth.json ────────────────────────────────────────────────── -if [ ! -f "$AUTH_FILE" ]; then - echo "{}" > "$AUTH_FILE" - chmod 600 "$AUTH_FILE" -fi - -active_key="${KEYS[$((new_idx-1))]}" - -# Build auth.json with all providers -# Primary "anthropic" gets the active key -# Additional "zai-1".."zai-N" get each key for fallback -TMP="${AUTH_FILE}.tmp.$$" - -python3 -c " -import json, sys - -auth_file = sys.argv[1] -active_key = sys.argv[2] -all_keys = sys.argv[3].split('\t') -new_idx = int(sys.argv[4]) - -try: - with open(auth_file) as f: - auth = json.load(f) -except: - auth = {} - -# Set active key as anthropic provider (Z.AI uses Anthropic API compat) -auth['anthropic'] = {'type': 'api', 'key': active_key} - -# Also set zai-coding-plan with active key -auth['zai-coding-plan'] = {'type': 'api', 'key': active_key} - -# Store all keys as numbered zai providers for manual fallback -for i, k in enumerate(all_keys, 1): - auth[f'zai-{i}'] = {'type': 'api', 'key': k} - -with open(auth_file, 'w') as f: - json.dump(auth, f, indent=2) -" "$AUTH_FILE" "$active_key" "$(printf '%s\t' "${KEYS[@]}")" "$new_idx" - -chmod 600 "$AUTH_FILE" 2>/dev/null || true - -# ── Update rotation state ───────────────────────────────────────────── -jq --arg now "$(now_iso)" --argjson idx "$new_idx" --argjson tot "$total" \ - '.["zai"] = {"current_index": $idx, "last_rotated": $now, "total_keys": $tot}' \ - "$STATE_FILE" > "${STATE_FILE}.tmp" && mv "${STATE_FILE}.tmp" "$STATE_FILE" - -masked=$(mask_key "$active_key") -echo "Rotated ZAI keys: index $cur_idx -> $new_idx (of $total) [$STRATEGY]" -echo "Active key: $masked" -echo "auth.json updated: $AUTH_FILE" diff --git a/docs/reports/t27b_expectations.json b/docs/reports/t27b_expectations.json index 09b79809ce..bf56e6fb13 100644 --- a/docs/reports/t27b_expectations.json +++ b/docs/reports/t27b_expectations.json @@ -1362,7 +1362,9 @@ {"path": "specs/queen/hive.t27", "t27b": "pass"}, {"path": "specs/queen/holds.t27", "t27b": "pass"}, {"path": "specs/queen/jobs.t27", "t27b": "pass"}, + {"path": "specs/queen/key_credit.t27", "t27b": "pass"}, {"path": "specs/queen/key_failover.t27", "t27b": "pass"}, + {"path": "specs/queen/key_vault.t27", "t27b": "pass"}, {"path": "specs/queen/keyed.t27", "t27b": "pass"}, {"path": "specs/queen/keyed_batch.t27", "t27b": "pass"}, {"path": "specs/queen/keyed_guard.t27", "t27b": "pass"}, diff --git a/specs/queen/key_credit.t27 b/specs/queen/key_credit.t27 new file mode 100644 index 0000000000..6bf32460b7 --- /dev/null +++ b/specs/queen/key_credit.t27 @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/queen/key_credit.t27 -- what a leased key earns its contributor: credit for .t27 that landed, and +; nothing for a request +; Owner, 2026-10-19 (translated): pay the TRI token as Proof of Compute, for $TRI in exchange for .t27. +; The unit is accepted .t27 work: a contributor's key earns when the bee or review turn it powered +; produced .t27 that passed review and landed. Not per request, not per token spent, not per key-hour. +; -- +; What the reward is, and what it is not (gHashTag/golden-chain-international FACTS.md section 6 and +; FINANCE_AND_COMPLIANCE.md section 3 are the source; nothing here restates them as new facts): +; - $TRI is minted only by the founder, Dmitrii Vasilev, on TON (trinet/mint_authority.t27, +; trinet/tri-bridge.t27 CHAIN_OF_RECORD). The Base Sepolia TriToken and its MiningPool are a retired +; testnet experiment, not $TRI, and nothing here is a claim on that pool. Measured 2026-10-19 on +; gHashTag/trinity-contracts src/MiningPool.sol: claim() takes an opaque proof_data it does not verify, +; signed by any key ChipRegistry.registerChip admitted, and registerChip has no gate (audit +; W-INTL-34). So a key turn could be put through it, and that would prove nothing: the pool is not a +; verifier of work, and it is not $TRI. +; - A credit is a reward for verified work. It is not a payment for a key, not a price of a request, +; and lending a key earns nothing by itself (automation/queen-contributor-keys.t27 CREDIT_WALLET +; false). Credits are ledger rows, never moved (verified/payout.t27 CREDIT_TRANSFERABLE false); no +; settlement exists (hosting/credit.t27 SETTLEMENT_ENABLED false, TOKEN_VALUE_CLAIMED false); the +; value gate is counsel's (hosting/statement.t27 VALUE_GATE_OPEN false); the mint schedule's rate is +; 0 (mint_authority.t27 SCHEDULE_BASE_RATE). This card adds no release path. +; -- +; The receipt: one row per lease that served a dispatch. It names the key by its id (KEY_ID_HEX_LEN hex +; of the SHA-256 of the key, verified/signed_receipt.t27), never by its value, and links: +; lease -> key id -> owner -> dispatch -> PR (its head is the dispatch's branch) -> the .t27 files the +; PR added or changed -> the merge commit on master -> the review that said ACCEPT on that head. +; The tokens the lease spent are recorded, and never priced: the unit is the landed PR. +; The facts a row carries are bits (R_*), as network/usage.t27 carries its U_* bits. The Queen measures +; them: the lease record (key_vault.t27), the dispatch state (queen/dispatch.t27), the PR and its merge +; (GitHub), the review gate (verified/review_gate.t27 G_POLICY: tests executed and passed, seals +; verified, policy clean). +; -- +; The rules: +; 1. only a contributor's key earns. A Queen pool key (a Railway variable) has no one to credit; the +; company crediting itself is not a reward for anyone's work (key_owner_earns); +; 2. a lease that was refused, overloaded or got no answer did no work: not eligible; +; 3. a receipt naming a key the lease record does not name is an identity mismatch; +; 4. a broken chain (the lease was not this dispatch's, the PR is not the dispatch's) is unverifiable, +; and charges nothing; +; 5. a PR that adds or changes no .t27 file earns nothing; neither does one not merged, not closing +; its task, or whose dispatch did not end in ACCEPT; +; 6. a contributor is credited once per PR, however many leases, keys or files served it: splitting a +; change into more files, more requests or more keys earns nothing more; +; 7. a key owner is never slashed: a bee's wrong answer is the bee's, not the key's. So no row is +; OUT_REJECTED_AND_SLASHED; +; 8. a credited row is paid only through payout(): the verdict must be verified and the row must name +; it. A landed PR is reproduced by CI, not device-bound, so testnet credit is allowed and value flow +; is not (payout.t27 attestation ladder). +; The amount: KEY_CREDIT_MTRI per credited row, the one rate the owner has set for accepted work +; (trinet/node-work-credit.t27 REWARD_PER_JOB_MTRI). A rate for a landed .t27 PR is the founder's to set; +; nothing here invents one. +; Not here: the wiring (trios: a receipt row per lease, the epoch statement leaf), and a revert after +; merge (a later issue: the row stays, and the revert is its own row). +; ASCII only (L3). +; phi^2 + 1/phi^2 = 3 | TRINITY + +module QueenKeyCredit; + +use verified::payout::OUT_CREDITED; +use verified::payout::OUT_REJECTED_AND_SLASHED; +use verified::payout::OUT_IDENTITY_MISMATCH; +use verified::payout::OUT_UNVERIFIABLE_NOT_CHARGED; +use verified::payout::OUT_NOT_ELIGIBLE; +use verified::payout::OUT_ALREADY_SETTLED_NOT_CHARGED; +use verified::payout::PAY_NONE; +use verified::payout::PAY_NORMAL; +use verified::payout::PAY_SLASH_ONLY; +use verified::payout::PROVIDER_FEE; +use verified::payout::CREDIT_TRANSFERABLE; +use verified::payout::payout; +use verified::payout::payment_kind_known; +use verified::payout::attestation_level; +use verified::payout::testnet_credit_allowed; +use verified::payout::value_flow_allowed; +use verified::signed_receipt::KEY_ID_HEX_LEN; +use trinet::node-work-credit::REWARD_PER_JOB_MTRI; +use queen::dispatch::S_ACCEPT; +use queen::dispatch::S_SENDBACK; +use queen::dispatch::S_EMPTY; +use queen::dispatch::S_REFUSED; + +; --- 1. Whose key --------------------------------------------------------------------------------------- + +/// A Queen pool key (a service variable) or a contributor's sealed key row. +pub const KO_QUEEN : u8 = 0; +pub const KO_CONTRIBUTOR : u8 = 1; + +/// A contributor's GitHub account id; 0 is no account. +pub const NO_ACCOUNT : u64 = 0; + +/// Only a contributor with a known account earns: a pool key has no one to credit. +pub fn key_owner_earns(owner_kind: u8, account: u64) -> bool { + if (owner_kind != KO_CONTRIBUTOR) { return false; } + return account != NO_ACCOUNT; +} + +; --- 2. The receipt ------------------------------------------------------------------------------------- + +/// The message layout of a receipt row (verified/signed_receipt.t27: a domain line, then name=value). +/// `key` is the key id, never the key; `tokens` is recorded, never priced. +pub const RECEIPT_DOMAIN : str = "t27-key-credit-receipt-v1"; +pub const RECEIPT_FIELDS : [14]str = ["lease", "key", "owner", "account", "dispatch", "state", "pr", "head", "merge", "t27_files", "review", "tokens", "outcome", "utc_unix"]; +pub const RECEIPT_FIELD_COUNT : u8 = 14; + +/// The facts of a row, one bit each. +pub const R_ANSWERED : u32 = 1; +pub const R_KEY_MATCHES : u32 = 2; +pub const R_LEASE_IN_DISPATCH : u32 = 4; +pub const R_PR_FROM_DISPATCH : u32 = 8; +pub const R_MERGED : u32 = 16; +pub const R_CLOSES_TASK : u32 = 32; +pub const R_CREDITED_BEFORE : u32 = 64; +pub const R_GATE_POLICY : u32 = 128; +pub const R_VERDICT_NAMED : u32 = 256; +pub const R_BITS : u32 = 9; + +pub fn fact(facts: u32, bit: u32) -> bool { + return (facts & bit) != 0; +} + +/// A key id is KEY_ID_HEX_LEN hex digits. Anything longer may be a key's value, and is refused. +pub fn key_id_ok(hex_len: u32, all_hex: bool) -> bool { + if (all_hex == false) { return false; } + return hex_len == (KEY_ID_HEX_LEN as u32); +} + +/// The ledger outcome of one receipt row, in payout.t27's codes. Never OUT_REJECTED_AND_SLASHED. +pub fn receipt_outcome(owner_kind: u8, account: u64, facts: u32, state: u32, t27_files: u32) -> u8 { + if (key_owner_earns(owner_kind, account) == false) { return OUT_NOT_ELIGIBLE; } + if (fact(facts, R_ANSWERED) == false) { return OUT_NOT_ELIGIBLE; } + if (fact(facts, R_KEY_MATCHES) == false) { return OUT_IDENTITY_MISMATCH; } + if (fact(facts, R_LEASE_IN_DISPATCH) == false) { return OUT_UNVERIFIABLE_NOT_CHARGED; } + if (fact(facts, R_PR_FROM_DISPATCH) == false) { return OUT_UNVERIFIABLE_NOT_CHARGED; } + if (state != S_ACCEPT) { return OUT_NOT_ELIGIBLE; } + if (t27_files == 0) { return OUT_NOT_ELIGIBLE; } + if (fact(facts, R_MERGED) == false) { return OUT_NOT_ELIGIBLE; } + if (fact(facts, R_CLOSES_TASK) == false) { return OUT_NOT_ELIGIBLE; } + if (fact(facts, R_CREDITED_BEFORE)) { return OUT_ALREADY_SETTLED_NOT_CHARGED; } + return OUT_CREDITED; +} + +/// The payment for one row: payout() of its outcome, verified when the review gate reached G_POLICY, +/// referenced when the row names that review. +pub fn receipt_pay(owner_kind: u8, account: u64, facts: u32, state: u32, t27_files: u32) -> u8 { + let outcome : u8 = receipt_outcome(owner_kind, account, facts, state, t27_files); + return payout(outcome, fact(facts, R_GATE_POLICY), fact(facts, R_VERDICT_NAMED)); +} + +; --- 3. The credit -------------------------------------------------------------------------------------- + +/// The kind every credited row is paid as: the key's owner provided the compute. +pub const KEY_CREDIT_KIND : u8 = 3; +/// mTRI per credited row: REWARD_PER_JOB_MTRI (an invariant holds them equal; gen-c cannot set a const +/// from an imported one). +pub const KEY_CREDIT_MTRI : u64 = 1; + +/// A landed PR is reproduced by CI and signed by no device: testnet credit, no value flow. +pub fn key_attestation() -> u8 { + return attestation_level(true, true, false); +} + +/// The mTRI one row adds to its contributor's balance. That the attestation allows testnet credit is +/// the invariant nothing_moves_and_nothing_settles, proved at compile time, so no runtime guard here. +pub fn credit_mtri(owner_kind: u8, account: u64, facts: u32, state: u32, t27_files: u32) -> u64 { + if (receipt_pay(owner_kind, account, facts, state, t27_files) != PAY_NORMAL) { return 0; } + return KEY_CREDIT_MTRI; +} + +; --- 4. Tests ------------------------------------------------------------------------------------------- + +/// Every fact that a credited row needs, and none it must not have. +pub const R_LANDED : u32 = 447; + +invariant the_codes_and_bits_are_distinct { + assert KO_QUEEN == 0 and KO_CONTRIBUTOR == 1; + assert R_ANSWERED + R_KEY_MATCHES + R_LEASE_IN_DISPATCH + R_PR_FROM_DISPATCH + R_MERGED + R_CLOSES_TASK + R_CREDITED_BEFORE + R_GATE_POLICY + R_VERDICT_NAMED == 511; + assert R_LANDED == 511 - R_CREDITED_BEFORE; + assert R_BITS == 9; + assert RECEIPT_FIELD_COUNT == 14; +} + +invariant the_rate_is_the_owners_one_rate { + assert KEY_CREDIT_MTRI == REWARD_PER_JOB_MTRI; + assert KEY_CREDIT_KIND == PROVIDER_FEE and payment_kind_known(KEY_CREDIT_KIND); +} + +invariant nothing_moves_and_nothing_settles { + assert CREDIT_TRANSFERABLE == false; + assert value_flow_allowed(key_attestation()) == false; + assert testnet_credit_allowed(key_attestation()); +} + +test a_landed_reviewed_t27_pr_is_credited_once { + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED, S_ACCEPT, 1) == OUT_CREDITED; + assert receipt_pay(KO_CONTRIBUTOR, 42, R_LANDED, S_ACCEPT, 1) == PAY_NORMAL; + assert credit_mtri(KO_CONTRIBUTOR, 42, R_LANDED, S_ACCEPT, 1) == KEY_CREDIT_MTRI; + assert credit_mtri(KO_CONTRIBUTOR, 42, R_LANDED | R_CREDITED_BEFORE, S_ACCEPT, 1) == 0; + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED | R_CREDITED_BEFORE, S_ACCEPT, 1) == OUT_ALREADY_SETTLED_NOT_CHARGED; +} + +test more_files_earn_nothing_more { + assert credit_mtri(KO_CONTRIBUTOR, 42, R_LANDED, S_ACCEPT, 1) == credit_mtri(KO_CONTRIBUTOR, 42, R_LANDED, S_ACCEPT, 40); +} + +test no_t27_file_no_credit { + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED, S_ACCEPT, 0) == OUT_NOT_ELIGIBLE; + assert credit_mtri(KO_CONTRIBUTOR, 42, R_LANDED, S_ACCEPT, 0) == 0; +} + +test a_pool_key_or_an_unknown_account_earns_nothing { + assert key_owner_earns(KO_QUEEN, 42) == false; + assert key_owner_earns(KO_CONTRIBUTOR, NO_ACCOUNT) == false; + assert key_owner_earns(KO_CONTRIBUTOR, 42); + assert credit_mtri(KO_QUEEN, 42, R_LANDED, S_ACCEPT, 3) == 0; + assert credit_mtri(KO_CONTRIBUTOR, NO_ACCOUNT, R_LANDED, S_ACCEPT, 3) == 0; +} + +test a_refused_or_unanswered_lease_did_no_work { + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED - R_ANSWERED, S_ACCEPT, 1) == OUT_NOT_ELIGIBLE; +} + +test a_key_the_lease_does_not_name_is_an_identity_mismatch { + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED - R_KEY_MATCHES, S_ACCEPT, 1) == OUT_IDENTITY_MISMATCH; + assert receipt_pay(KO_CONTRIBUTOR, 42, R_LANDED - R_KEY_MATCHES, S_ACCEPT, 1) == PAY_NONE; +} + +test a_broken_chain_is_unverifiable_and_charges_nothing { + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED - R_LEASE_IN_DISPATCH, S_ACCEPT, 1) == OUT_UNVERIFIABLE_NOT_CHARGED; + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED - R_PR_FROM_DISPATCH, S_ACCEPT, 1) == OUT_UNVERIFIABLE_NOT_CHARGED; +} + +test only_an_accepted_dispatch_earns { + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED, S_SENDBACK, 1) == OUT_NOT_ELIGIBLE; + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED, S_EMPTY, 1) == OUT_NOT_ELIGIBLE; + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED, S_REFUSED, 1) == OUT_NOT_ELIGIBLE; +} + +test unmerged_or_untraced_work_earns_nothing { + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED - R_MERGED, S_ACCEPT, 1) == OUT_NOT_ELIGIBLE; + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED - R_CLOSES_TASK, S_ACCEPT, 1) == OUT_NOT_ELIGIBLE; +} + +test a_credit_needs_a_verified_named_verdict { + assert receipt_outcome(KO_CONTRIBUTOR, 42, R_LANDED - R_GATE_POLICY, S_ACCEPT, 1) == OUT_CREDITED; + assert credit_mtri(KO_CONTRIBUTOR, 42, R_LANDED - R_GATE_POLICY, S_ACCEPT, 1) == 0; + assert credit_mtri(KO_CONTRIBUTOR, 42, R_LANDED - R_VERDICT_NAMED, S_ACCEPT, 1) == 0; +} + +// Every one of the 512 fact sets, for a contributor and a pool key, at each of a few dispatch states: +// credit only for exactly the landed set, a key owner is never slashed, and every refusal pays nothing. +test only_the_whole_chain_is_credited_and_no_key_is_slashed { + let states : [4]u32 = [S_ACCEPT, S_SENDBACK, S_EMPTY, S_REFUSED]; + var f : u32 = 0; + while (f < 512) { + var s : u32 = 0; + while (s < 4) { + let st : u32 = states[s]; + let o : u8 = receipt_outcome(KO_CONTRIBUTOR, 42, f, st, 2); + assert o != OUT_REJECTED_AND_SLASHED; + assert receipt_pay(KO_CONTRIBUTOR, 42, f, st, 2) != PAY_SLASH_ONLY; + let whole : bool = (f == R_LANDED) and (st == S_ACCEPT); + assert (credit_mtri(KO_CONTRIBUTOR, 42, f, st, 2) == KEY_CREDIT_MTRI) == whole; + assert credit_mtri(KO_QUEEN, 42, f, st, 2) == 0; + s = s + 1; + } + f = f + 1; + } +} + +test a_key_is_named_by_its_id_never_its_value { + assert key_id_ok(16, true); + assert key_id_ok(16, false) == false; + assert key_id_ok(15, true) == false; + assert key_id_ok(64, true) == false; + assert key_id_ok(49, true) == false; +} diff --git a/specs/queen/key_vault.t27 b/specs/queen/key_vault.t27 new file mode 100644 index 0000000000..341c272cb4 --- /dev/null +++ b/specs/queen/key_vault.t27 @@ -0,0 +1,351 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/queen/key_vault.t27 -- every provider key lives in the Queen's one store; every consumer leases from it +; Owner, 2026-10-18 (translated): all API keys -- the Queen's pools and every contributor's key -- live +; safely in ONE place and are redistributed among everyone who uses them. The Mac reviewer must not run +; on its own keys outside the shared pool. +; Measured 2026-10-19 (names only, no value read): +; - the Queen's pools are Railway variables: pool 1 (TRIOS_QUEEN_WORKER_API_KEY and _1.._24) and pool 2 +; (TRIOS_QUEEN_WORKER_POOL_2_API_KEY and _1.._9), copied to trios-agent-server, trios-bee-runner-1 +; and trios-bee-runner-2 -- three copies of each key, all inside the Queen's own services; +; - contributor keys are rows in the Queen's Postgres, sealed with AES-256-GCM (trios +; queen-contributor-keys.ts); +; - the Mac reviewer (tools/bees/reviewer.py, launchd ai.t27.reviewer-bees) reads five more z.ai keys, +; ZAI_KEY_1, _2, _3, _5, _6, from ~/.claude/.env: a second home the Queen cannot see. Its reviews and +; the Queen's bees can hit one key at once, and neither side knows the other holds it; +; - at the same time the Queen ran 70 bees with 354 unreviewed dispatches and about 5 reviews an hour: +; review, not lanes, is the bottleneck. +; The rule here: +; 1. a key the Queen holds (a service variable or a sealed contributor row) is in the store; a key one +; consumer holds alone is private, and nothing serves from it (may_serve); +; 2. a consumer leases from the store. One that holds private keys while a lease is configured is +; refused: two homes for one key. One with no lease yet runs on its own keys and says so, only +; until the lease exists (consumer_start); +; 3. the lanes are shared by demand. A z.ai key carries ZAI_REQUESTS_PER_KEY requests +; (reviewer_sizing.t27). Reviews that hold or wait for a lane get the lanes first, never fewer than +; one key's worth; bees get the rest (review_share, worker_share). A free lane goes to a waiting +; review before a bee. Inside review, no holder takes more than its share of the holders, except +; lanes nobody else is asking for, which are lent (holder_cap, lease_answer); +; 4. a lease covers one request. The consumer never sees a key's value: the store makes the request on +; a lane and fails over to the next lane on a refusal (lease_step, after key_failover.t27 +; error_kind). A held lease is never taken back; the share moves when leases end. +; The admission of new bees while reviews wait is a sibling card, not this one. Not here: the wiring +; (trios: an authenticated lease route on the Queen; the Mac: one switch). ASCII only (L3). +; phi^2 + 1/phi^2 = 3 | TRINITY + +module QueenKeyVault; + +use queen::key_failover::error_kind; +use queen::key_failover::KE_OK; +use queen::key_failover::KE_TRANSIENT; +use queen::key_failover::KE_QUOTA; +use queen::key_failover::KE_AUTH; +use queen::key_failover::KE_REQUEST; +use queen::key_failover::COOL_BASE_SECONDS; +use queen::reviewer_sizing::ZAI_REQUESTS_PER_KEY; +use queen::reviewer_sizing::U32_MAX; + +; --- 1. Where a key lives ---------------------------------------------------------------------------- + +/// SRC_STORE: a key the Queen holds -- a variable of her own services or a sealed contributor row. +/// SRC_PRIVATE: a key one consumer holds alone, outside the store (a dotenv file on a Mac, another +/// repository's Actions secret). Any other source is unknown. +pub const SRC_STORE : u8 = 0; +pub const SRC_PRIVATE : u8 = 1; + +/// Only the store serves a lease. A private key is never leased, never pooled, never read as the +/// store's: the Queen cannot see who else holds it, so she cannot share it. +pub fn may_serve(src: u8) -> bool { + return src == SRC_STORE; +} + +; --- 2. Who may start -------------------------------------------------------------------------------- + +/// START_LEASE: the consumer leases from the store. START_OWN_KEYS: no lease is configured yet; it runs +/// on its own keys and says so on every start, until the lease exists. START_REFUSE: it may not start. +pub const START_LEASE : u8 = 0; +pub const START_OWN_KEYS : u8 = 1; +pub const START_REFUSE : u8 = 2; + +/// `private_keys` is the count of provider keys the consumer holds itself, `lease_on` whether a lease +/// from the store is configured. Both at once is two homes for one key: refused, so moving the keys +/// into the store is the only way on. Neither is a consumer with no key at all. +pub fn consumer_start(private_keys: u32, lease_on: bool) -> u8 { + if (lease_on) { + if (private_keys > 0) { return START_REFUSE; } + return START_LEASE; + } + if (private_keys > 0) { return START_OWN_KEYS; } + return START_REFUSE; +} + +/// Whether the start is logged as running outside the store. +pub fn start_warns(answer: u8) -> bool { + return answer == START_OWN_KEYS; +} + +; --- 3. How the lanes are shared --------------------------------------------------------------------- + +/// LEASE_REVIEW: a review's model call (the round reviewer, the Queen app's review, the Mac reviewer). +/// LEASE_WORKER: a bee's turn. +pub const LEASE_REVIEW : u8 = 0; +pub const LEASE_WORKER : u8 = 1; + +/// Reviews never get fewer lanes than one key carries, even while none is asking: the next review +/// finds a lane without waiting for a bee to end. Written as a literal and pinned to +/// ZAI_REQUESTS_PER_KEY by an invariant: gen-c emits a const set from an imported const before the +/// import, and that C does not compile. +pub const REVIEW_FLOOR_LANES : u32 = 2; + +/// The review holders that ask the store for lanes: the round reviewer, the Queen app's review (with +/// the shaper, through the free lane), and the Mac reviewer through the lease route. Chosen, not +/// measured: one share each. +pub const REVIEW_HOLDERS : u32 = 3; + +/// The lanes `keys` z.ai keys carry. Saturates rather than wraps. +pub fn lanes_of(keys: u32) -> u32 { + if (keys > U32_MAX / ZAI_REQUESTS_PER_KEY) { return U32_MAX; } + return keys * ZAI_REQUESTS_PER_KEY; +} + +/// The lanes reviews may hold: what they ask for -- the reviews holding a lane and the ones waiting +/// for one (`review_demand`) -- never below REVIEW_FLOOR_LANES, never above all the lanes. Demand is +/// lanes asked for now, not the backlog of unreviewed work: a backlog of 354 rows that 8 reviewers +/// serve wants 8 lanes, and giving it 68 would leave 60 idle while bees wait. +/// (Mutation: `<` to `<=` and `>` to `>=` below are equivalent -- at equality both branches give the +/// same number.) +pub fn review_share(total: u32, review_demand: u32) -> u32 { + var want : u32 = review_demand; + if (want < REVIEW_FLOOR_LANES) { want = REVIEW_FLOOR_LANES; } + if (want > total) { return total; } + return want; +} + +/// The lanes bees may hold: all the rest. +pub fn worker_share(total: u32, review_demand: u32) -> u32 { + return total - review_share(total, review_demand); +} + +/// One review holder's part of the review share: the share split over the holders, rounded up, so a +/// share of one lane still lets one holder in. 0 when the share is 0 (0 / holders, no guard needed). +/// No holders is one holder: the whole share. +pub fn holder_cap(share: u32, holders: u32) -> u32 { + if (holders == 0) { return share; } + var part : u32 = share / holders; + if (share % holders != 0) { part = part + 1; } + return part; +} + +/// LA_GRANT: take a lane now. LA_WAIT: none for this asker now; ask again after retry_after_seconds. +/// LA_REFUSE: never, whatever the lanes. +pub const LA_GRANT : u8 = 0; +pub const LA_WAIT : u8 = 1; +pub const LA_REFUSE : u8 = 2; + +/// One ask for a lease. `held` is the lanes this asker already holds: one review holder's, or all the +/// bees' together for LEASE_WORKER. `free_lanes` is the lanes no request holds now, `total` all the +/// lanes. `review_holding` and `review_waiting` are the reviews holding and waiting for a lane; a +/// review's own ask counts as waiting. (Mutation: `>` to `>=` in the saturating sum is equivalent -- +/// at equality the sum is U32_MAX either way.) +/// - an unknown kind, or a source other than the store, is refused; +/// - a review takes a lane while its holder is under holder_cap. Over it, it still takes a lane when +/// more than holder_cap lanes are free: nobody else is asking for those, so they are lent, and +/// holder_cap lanes stay free for the other holders; +/// - a bee waits while every free lane is a waiting review's, and while the bees hold their share. +pub fn lease_answer(kind: u8, src: u8, held: u32, free_lanes: u32, total: u32, review_holding: u32, review_waiting: u32) -> u8 { + if (kind > LEASE_WORKER) { return LA_REFUSE; } + if (may_serve(src) == false) { return LA_REFUSE; } + if (free_lanes == 0) { return LA_WAIT; } + var demand : u32 = review_holding; + if (demand > U32_MAX - review_waiting) { demand = U32_MAX; } else { demand = demand + review_waiting; } + if (kind == LEASE_REVIEW) { + var cap : u32 = holder_cap(review_share(total, demand), REVIEW_HOLDERS); + if (held < cap) { return LA_GRANT; } + if (free_lanes > cap) { return LA_GRANT; } + return LA_WAIT; + } + if (free_lanes <= review_waiting) { return LA_WAIT; } + if (held >= worker_share(total, demand)) { return LA_WAIT; } + return LA_GRANT; +} + +/// Seconds a waiting asker waits before it asks again: the short rest key_failover.t27 gives a +/// transient refusal, so a wait never outlasts a lane's own rest. 0 for any other answer. +pub fn retry_after_seconds(answer: u8) -> u32 { + if (answer == LA_WAIT) { return COOL_BASE_SECONDS; } + return 0; +} + +; --- 4. One leased request --------------------------------------------------------------------------- + +/// The store makes the request on a lane; the consumer gets the answer, never the key. A lease covers +/// one request and lasts at most LEASE_SECONDS: the Mac reviewer's own request timeout (API_TIMEOUT_MS +/// 600000 in tools/bees/reviewer.py), so the store never cuts a request the consumer still waits for. +pub const LEASE_SECONDS : u32 = 600; + +/// A refused request tries at most LEASE_TRIES lanes, each once: the free lane's MAX_LANES in trios +/// queen-free-lane.ts. +pub const LEASE_TRIES : u32 = 3; + +/// LS_ANSWER: hand this answer to the consumer. LS_NEXT_LANE: try the request on the next lane. +pub const LS_ANSWER : u8 = 0; +pub const LS_NEXT_LANE : u8 = 1; + +/// `status` and `zai_code` are the lane's answer (key_failover.t27 error_kind), `tries` the lanes tried +/// so far, this one included. A refusal of the lane (transient, quota, auth) moves to the next lane +/// while tries are left; an answer or a wrong request goes back as it is, because every lane would +/// answer a wrong request the same. +pub fn lease_step(status: u32, zai_code: u32, tries: u32) -> u8 { + var kind : u8 = error_kind(status, zai_code); + if (kind == KE_OK) { return LS_ANSWER; } + if (kind == KE_REQUEST) { return LS_ANSWER; } + if (tries >= LEASE_TRIES) { return LS_ANSWER; } + return LS_NEXT_LANE; +} + +; --- 5. Tests ----------------------------------------------------------------------------------------- + +test only_the_store_serves { + assert may_serve(SRC_STORE) == true; + // negative controls: a private key, an unknown source + assert may_serve(SRC_PRIVATE) == false; + assert may_serve(7) == false; +} + +test a_consumer_with_two_homes_is_refused { + // the Mac reviewer after the move: the lease, no key of its own + assert consumer_start(0, true) == START_LEASE; + // the Mac reviewer today: five keys in ~/.claude/.env, no lease yet + assert consumer_start(5, false) == START_OWN_KEYS; + assert start_warns(consumer_start(5, false)) == true; + // negative control: the lease configured while the five keys stay in the dotenv + assert consumer_start(5, true) == START_REFUSE; + assert consumer_start(1, true) == START_REFUSE; + // no key and no lease: nothing to run on + assert consumer_start(0, false) == START_REFUSE; + assert start_warns(START_LEASE) == false; + assert start_warns(START_REFUSE) == false; +} + +test lanes_follow_the_keys { + // pools 1 and 2: 24 + 10 keys + assert lanes_of(34) == 68; + assert lanes_of(0) == 0; + assert lanes_of(1) == REVIEW_FLOOR_LANES; + assert lanes_of(2147483647) == 4294967294; + assert lanes_of(2147483648) == U32_MAX; +} + +test reviews_get_the_lanes_they_ask_for_first { + // idle: one key's worth stays with review, bees get the rest + assert review_share(68, 0) == 2; + assert worker_share(68, 0) == 66; + // 8 reviewers busy, 4 waiting: 12 lanes, not the 354-row backlog + assert review_share(68, 12) == 12; + assert worker_share(68, 12) == 56; + // more demand than lanes: review takes them all, bees none + assert review_share(68, 354) == 68; + assert worker_share(68, 354) == 0; + // fewer lanes than the floor + assert review_share(1, 0) == 1; + assert worker_share(1, 0) == 0; + assert review_share(0, 5) == 0; + assert worker_share(0, 5) == 0; +} + +test a_holder_takes_its_part_of_the_share { + assert holder_cap(12, 3) == 4; + assert holder_cap(13, 3) == 5; + assert holder_cap(2, 3) == 1; + assert holder_cap(1, 3) == 1; + assert holder_cap(0, 3) == 0; + assert holder_cap(68, 1) == 68; + assert holder_cap(68, 0) == 68; + assert holder_cap(U32_MAX, 2) == 2147483648; +} + +/// The conformance table: one assert per row. Columns: kind, source, held, free lanes, total lanes, +/// reviews holding, reviews waiting. +test conformance_rows { + // r01 the Mac's first review, the pool idle: granted + assert lease_answer(LEASE_REVIEW, SRC_STORE, 0, 60, 68, 0, 1) == LA_GRANT; + // r02 negative control: a private source is refused, lanes or not + assert lease_answer(LEASE_REVIEW, SRC_PRIVATE, 0, 60, 68, 0, 1) == LA_REFUSE; + // r03 negative control: an unknown kind is refused + assert lease_answer(2, SRC_STORE, 0, 60, 68, 0, 1) == LA_REFUSE; + assert lease_answer(9, SRC_STORE, 0, 60, 68, 0, 1) == LA_REFUSE; + // r04 no lane free: wait, review or bee + assert lease_answer(LEASE_REVIEW, SRC_STORE, 0, 0, 68, 10, 1) == LA_WAIT; + assert lease_answer(LEASE_WORKER, SRC_STORE, 0, 0, 68, 0, 0) == LA_WAIT; + // r05 a holder at its cap (share 12, cap 4) with 3 lanes free: the rest are the others', wait + assert lease_answer(LEASE_REVIEW, SRC_STORE, 4, 3, 68, 11, 1) == LA_WAIT; + // r06 the same holder with 10 lanes free: nobody else asks for them, lent + assert lease_answer(LEASE_REVIEW, SRC_STORE, 4, 10, 68, 11, 1) == LA_GRANT; + // r07 the cap boundary: free lanes equal to the cap are kept for the others + assert lease_answer(LEASE_REVIEW, SRC_STORE, 4, 4, 68, 11, 1) == LA_WAIT; + assert lease_answer(LEASE_REVIEW, SRC_STORE, 4, 5, 68, 11, 1) == LA_GRANT; + // r08 a holder under its cap takes the last free lane + assert lease_answer(LEASE_REVIEW, SRC_STORE, 3, 1, 68, 11, 1) == LA_GRANT; + // r09 negative control: a bee asks for the one free lane while a review waits: the review's + assert lease_answer(LEASE_WORKER, SRC_STORE, 50, 1, 68, 8, 1) == LA_WAIT; + assert lease_answer(LEASE_WORKER, SRC_STORE, 50, 3, 68, 8, 3) == LA_WAIT; + // r10 more free lanes than waiting reviews: a bee under its share gets one + assert lease_answer(LEASE_WORKER, SRC_STORE, 50, 4, 68, 8, 3) == LA_GRANT; + // r11 the bees at their share (68 - 11 = 57): wait + assert lease_answer(LEASE_WORKER, SRC_STORE, 57, 4, 68, 8, 3) == LA_WAIT; + assert lease_answer(LEASE_WORKER, SRC_STORE, 56, 4, 68, 8, 3) == LA_GRANT; + // r12 review idle: bees may fill all but one key's lanes + assert lease_answer(LEASE_WORKER, SRC_STORE, 65, 3, 68, 0, 0) == LA_GRANT; + assert lease_answer(LEASE_WORKER, SRC_STORE, 66, 2, 68, 0, 0) == LA_WAIT; + // r13 demand past every lane: no bee lease at all + assert lease_answer(LEASE_WORKER, SRC_STORE, 0, 10, 68, 60, 300) == LA_WAIT; + // r14 a review with a saturating count still gets an answer, not a wrap + assert lease_answer(LEASE_REVIEW, SRC_STORE, 0, 5, 68, U32_MAX, U32_MAX) == LA_GRANT; +} + +test a_wait_says_when_to_ask_again { + assert retry_after_seconds(LA_WAIT) == COOL_BASE_SECONDS; + assert retry_after_seconds(LA_GRANT) == 0; + assert retry_after_seconds(LA_REFUSE) == 0; +} + +test a_refused_lane_hands_the_request_to_the_next { + // answered + assert lease_step(200, 0, 1) == LS_ANSWER; + // 1305 overload, 1302 rate limit, a spent quota, a refused key: next lane + assert lease_step(429, 1305, 1) == LS_NEXT_LANE; + assert lease_step(429, 1302, 2) == LS_NEXT_LANE; + assert lease_step(429, 1113, 1) == LS_NEXT_LANE; + assert lease_step(401, 0, 1) == LS_NEXT_LANE; + assert lease_step(0, 0, 1) == LS_NEXT_LANE; + assert lease_step(503, 0, 2) == LS_NEXT_LANE; + // the bound: the third lane's refusal goes back to the consumer + assert lease_step(429, 1305, LEASE_TRIES) == LS_ANSWER; + assert lease_step(429, 1305, LEASE_TRIES + 1) == LS_ANSWER; + // negative control: a wrong request is the consumer's, never retried on another lane + assert lease_step(400, 0, 1) == LS_ANSWER; + assert lease_step(404, 0, 1) == LS_ANSWER; +} + +; --- 6. Invariants ----------------------------------------------------------------------------------- + +invariant the_codes_are_distinct { + assert SRC_STORE == 0 and SRC_PRIVATE == 1; + assert START_LEASE == 0 and START_OWN_KEYS == 1 and START_REFUSE == 2; + assert LEASE_REVIEW == 0 and LEASE_WORKER == 1; + assert LA_GRANT == 0 and LA_WAIT == 1 and LA_REFUSE == 2; + assert LS_ANSWER == 0 and LS_NEXT_LANE == 1; +} + +/// The review floor is one key's lanes, and a holder's part of it is never 0. +invariant the_floor_is_one_key { + assert REVIEW_FLOOR_LANES == ZAI_REQUESTS_PER_KEY and REVIEW_FLOOR_LANES >= 1; + assert REVIEW_HOLDERS == 3 and holder_cap(REVIEW_FLOOR_LANES, REVIEW_HOLDERS) >= 1; +} + +/// A lease outlasts a review's model call and a wait is shorter than a lease; the tries leave room for +/// a second and a third lane. +invariant the_bounds_fit { + assert LEASE_SECONDS == 600 and COOL_BASE_SECONDS < LEASE_SECONDS; + assert LEASE_TRIES == 3 and LEASE_TRIES > 1; + assert KE_TRANSIENT != KE_OK and KE_QUOTA != KE_REQUEST and KE_AUTH != KE_REQUEST; +} diff --git a/tools/bees/reviewer.py b/tools/bees/reviewer.py index 6f4b6575b6..df482cf5a1 100755 --- a/tools/bees/reviewer.py +++ b/tools/bees/reviewer.py @@ -1100,6 +1100,9 @@ def claude_argv(prompt, checkout, model, max_turns, budget, fallback=None): # ZAI_KEY_1.. (~/.claude/.env) and ZAI_API_KEY, ZAI_API_KEY_2.. (the Queen's pool # names). Not ZAI_API: on this machine that one is the endpoint URL. ZAI_KEY_RE = re.compile(r"ZAI_(?:API_)?KEY(?:_(\d+))?") +# The Queen's lease route and its token (specs/queen/key_vault.t27, #10564): with both set the agent +# calls her store and holds no z.ai key; a z.ai key kept here beside them is refused (consumer_start). +ENV_LEASE_URL, ENV_LEASE_TOKEN = "BEE_QUEEN_LEASE_URL", "BEE_QUEEN_LEASE_TOKEN" # Another provider's login or model choice would route the agent past z.ai, or # past the Keychain token: the agent gets exactly the provider set here. PROVIDER_STRIP = ("ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_API_KEY", "CLAUDE_CODE_OAUTH_TOKEN", @@ -1150,9 +1153,19 @@ def zai_keys(env=None, env_file=None): return keys +def queen_lease(env=None, env_file=None): + """(url, token) of the Queen's lease route, environment first, then the dotenv; None if unset.""" + env = os.environ if env is None else env + path = pathlib.Path(env_file or env.get(ENV_ZAI_FILE) or ZAI_ENV_FILE) + got = dict(env_file_pairs(path)) if path.is_file() else {} + got.update({k: v for k, v in env.items() if k in (ENV_LEASE_URL, ENV_LEASE_TOKEN) and v}) + url, token = got.get(ENV_LEASE_URL), got.get(ENV_LEASE_TOKEN) + return (url, token) if url and token else None + + def agent_env(env=None): env = dict(os.environ if env is None else env) - for k in STRIP_ENV + PROVIDER_STRIP: + for k in STRIP_ENV + PROVIDER_STRIP + (ENV_LEASE_TOKEN,): env.pop(k, None) for k in [k for k in env if ZAI_KEY_RE.fullmatch(k)]: env.pop(k) # the agent gets one key, as ANTHROPIC_AUTH_TOKEN, not the pool @@ -1215,11 +1228,21 @@ def __init__(self, provider="zai", model=None, keys=(), claude_token=None, runne self.pool = KeyPool(keys) self.claude_token = claude_token self.runner = runner or run_agent + self.base_url = ZAI_BASE_URL @classmethod def configured(cls, provider, model=None): if provider == "zai": - keys = zai_keys() + keys, lease = zai_keys(), queen_lease() + if lease and keys: # key_vault.t27 consumer_start: two homes for one key + raise AgentUnavailable(f"{len(keys)} z.ai key(s) kept here beside the Queen's lease: move them " + "into her store, then delete them here (specs/queen/key_vault.t27)") + if lease: + agent = cls("zai", model, keys=[lease[1]]) + agent.base_url = lease[0] + return agent + if keys: + log(f"running on {len(keys)} private z.ai key(s) outside the Queen's store (key_vault.t27)") if not keys: raise AgentUnavailable("no z.ai key found. " + HINT["zai"]) return cls("zai", model, keys=keys) @@ -1229,7 +1252,7 @@ def configured(cls, provider, model=None): def env(self, key=None, base=None): env = agent_env(base) if self.provider == "zai": - env.update({"ANTHROPIC_BASE_URL": ZAI_BASE_URL, "ANTHROPIC_AUTH_TOKEN": key, + env.update({"ANTHROPIC_BASE_URL": self.base_url, "ANTHROPIC_AUTH_TOKEN": key, # any background call the CLI makes goes to the same free model "ANTHROPIC_SMALL_FAST_MODEL": self.model, "ANTHROPIC_DEFAULT_HAIKU_MODEL": self.model, @@ -1246,7 +1269,7 @@ def twin(self, model): """The same provider, keys and runner on another model, with no fallback: a fallback could land the second opinion on the first one's model.""" t = Agent(self.provider, model, claude_token=self.claude_token, runner=self.runner) - t.pool, t.fallback = self.pool, None + t.pool, t.fallback, t.base_url = self.pool, None, self.base_url return t def run(self, argv, cwd, timeout, key=None): @@ -2322,8 +2345,9 @@ def doctor_findings(fix=False, launchd=launchd_job, now=None, version=claude_ver "the installed copy imports the Queen's criterion gate" if probe.returncode == 0 else "the installed copy cannot import the criterion gate, so it measures nothing: " + (probe.stderr.strip().splitlines() or ["?"])[-1][:160]) - keys = zai_keys() - add("ok" if keys else "fail", "keys", f"{len(keys)} z.ai key(s) in the pool (names only, never values)") + keys, lease = zai_keys(), queen_lease() + add("fail" if keys and lease else "ok" if lease else "warn" if keys else "fail", "keys", + f"{len(keys)} private z.ai key(s), Queen lease {'set' if lease else 'unset'} (key_vault.t27; names only)") t27c = find_t27c() add("ok" if t27c else "warn", "t27c", t27c or "none: every criterion that calls t27c is unrunnable") level, text, repaired = tamper_check(read_tamper(), version(), tamper if fix else None) @@ -2785,6 +2809,10 @@ def read_any(g): # a JSONDecodeError escapes every BeeError handler; here it is check("z.ai keys: the file is named by BEE_ZAI_ENV_FILE", zai_keys({ENV_ZAI_FILE: str(tmp / "env")}) == ["one", "two", "ten"]) check("z.ai keys: none is an empty list", zai_keys({}, tmp / "missing") == []) + lease = {ENV_LEASE_URL: "https://q.test/zai", ENV_LEASE_TOKEN: "lt"} + check("queen lease: both names or none, and the agent never sees its token", + queen_lease(lease, tmp / "env") == ("https://q.test/zai", "lt") and queen_lease({ENV_LEASE_URL: "u"}, + tmp / "missing") is None and ENV_LEASE_TOKEN not in agent_env(lease)) shutil.rmtree(tmp, ignore_errors=True) pool = KeyPool(["a", "b", "c"])