From f790dd156fad8f652f829ca4fb5e1fe0b73fada7 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 02:36:01 +0800 Subject: [PATCH 01/85] =?UTF-8?q?feat:=20#216=20agent=20cred-fetch=20?= =?UTF-8?q?=E2=80=94=20CLI=20consumer=20+=20real=20e2e=20(VERIFIED=20again?= =?UTF-8?q?st=20live=20infra)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-facing consumer of the #216 cred-fetch primitive, verified end-to-end against the LIVE broker + cred worker: - agentkeys-cli: `agentkeys cred fetch ` (cred_admin.rs) — mints a master-self/agent CredFetch cap → BackendClient.cred_fetch → STS → cred worker → decrypt → prints the plaintext. Adds the agentkeys-backend-client dep (the #204 one-owner path; no re-typed wire shapes). - harness/cred-fetch-demo.sh — the real e2e: a master VAULTS a probe cred via the daemon (web path), then the agent FETCHES it via the CLI (agent path), asserting the EXACT secret round-trips through cap-mint → STS → cred worker → S3 → decrypt. Idempotent (fixed `cred-e2e-probe`), --ci-tolerant, real-only. Contract-compliant (STEP_TOTAL=4, ok/skip/fail, EXIT-trap daemon cleanup). - keep-docs-in-sync: harness/CLAUDE.md orchestrator table + operator-runbook-harness.md. VERIFIED LIVE (this run): master vaulted via daemon (HTTP 200), agent `cred fetch` returned the EXACT key (len matched) — broker.litentry.org + cred.litentry.org. #216's cred half is proven, not just compiled. Remaining #216: the Hermes wire (phase1-wire Phase 4.0) — plant the fetched key into Hermes instead of $OPENROUTER_API_KEY (the full sandbox surprise). --- Cargo.lock | 1 + crates/agentkeys-cli/Cargo.toml | 1 + crates/agentkeys-cli/src/cred_admin.rs | 67 ++++++++++++++ crates/agentkeys-cli/src/lib.rs | 1 + crates/agentkeys-cli/src/main.rs | 59 ++++++++++++ docs/operator-runbook-harness.md | 1 + harness/CLAUDE.md | 1 + harness/cred-fetch-demo.sh | 121 +++++++++++++++++++++++++ 8 files changed, 252 insertions(+) create mode 100644 crates/agentkeys-cli/src/cred_admin.rs create mode 100755 harness/cred-fetch-demo.sh diff --git a/Cargo.lock b/Cargo.lock index ad0a72b2..f8a0fd6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -111,6 +111,7 @@ dependencies = [ name = "agentkeys-cli" version = "0.1.0" dependencies = [ + "agentkeys-backend-client", "agentkeys-core", "agentkeys-memory-engine", "agentkeys-memory-openviking", diff --git a/crates/agentkeys-cli/Cargo.toml b/crates/agentkeys-cli/Cargo.toml index 7563e8b3..c9d56bdf 100644 --- a/crates/agentkeys-cli/Cargo.toml +++ b/crates/agentkeys-cli/Cargo.toml @@ -17,6 +17,7 @@ agentkeys-core = { workspace = true } agentkeys-memory-engine = { workspace = true } agentkeys-memory-openviking = { workspace = true } agentkeys-provisioner = { path = "../agentkeys-provisioner" } +agentkeys-backend-client = { workspace = true } # #216 cred-fetch primitive (agent vaulted-key) clap = { version = "4", features = ["derive", "env"] } tokio = { workspace = true } serde_json = { workspace = true } diff --git a/crates/agentkeys-cli/src/cred_admin.rs b/crates/agentkeys-cli/src/cred_admin.rs new file mode 100644 index 00000000..d0b37164 --- /dev/null +++ b/crates/agentkeys-cli/src/cred_admin.rs @@ -0,0 +1,67 @@ +//! Agent-side credential fetch (#216) — the agent pulls its AUTHORIZED +//! credential (e.g. its LLM key) from the vault to *use* it. Unlike the master's +//! store/list (which never reveal a secret), this returns the decrypted +//! plaintext: the agent needs the actual secret to make calls. It is gated by the +//! agent's `cred:` scope — the broker won't mint a cred-fetch cap the +//! actor isn't scoped for, and the worker re-checks the cap. +//! +//! Routes through the shared `agentkeys-backend-client` (issue #204): cap-mint +//! (`CredFetch`) → per-actor STS under the VAULT role → cred worker +//! `/v1/cred/fetch` → decrypt → plaintext. No re-typed wire shapes. + +use anyhow::{Context, Result}; +use base64::{engine::general_purpose::STANDARD, Engine as _}; + +use agentkeys_backend_client::{ + normalize_omni_0x, BackendClient, CapMintOp, CapMintRequest, CredFetchInput, +}; + +/// Fetch + decrypt the credential `service` the actor is authorized for, returning +/// the plaintext secret. `operator_omni` == `actor_omni` for a master-self fetch; +/// for an agent they are (master, agent). The omnis are normalized to the broker's +/// `0x`-prefixed shape (issue #200 — the bare-vs-0x drift normalizer). +#[allow(clippy::too_many_arguments)] +pub async fn cred_fetch( + service: &str, + operator_omni: &str, + actor_omni: &str, + device_key_hash: &str, + session_bearer: &str, + broker_url: &str, + cred_url: &str, + vault_role_arn: &str, + region: &str, +) -> Result { + let client = BackendClient::new( + Some(broker_url.to_string()), + None, // memory_url + None, // audit_url + Some(cred_url.to_string()), + Some(session_bearer.to_string()), // agent_session_bearer → per-actor STS + None, // memory_role_arn + Some(vault_role_arn.to_string()), + region.to_string(), + ); + let cap = client + .cap_mint( + CapMintOp::CredFetch, + CapMintRequest { + operator_omni: normalize_omni_0x(operator_omni), + actor_omni: normalize_omni_0x(actor_omni), + service: service.to_string(), + device_key_hash: device_key_hash.to_string(), + ttl_seconds: 300, + }, + session_bearer, + ) + .await + .with_context(|| format!("cap-mint cred-fetch for service `{service}`"))?; + let result = client + .cred_fetch(CredFetchInput { cap }) + .await + .with_context(|| format!("cred worker fetch for service `{service}`"))?; + let bytes = STANDARD + .decode(&result.plaintext_b64) + .context("decode cred plaintext_b64")?; + String::from_utf8(bytes).context("cred plaintext is not valid UTF-8") +} diff --git a/crates/agentkeys-cli/src/lib.rs b/crates/agentkeys-cli/src/lib.rs index d94f486b..3339b80c 100644 --- a/crates/agentkeys-cli/src/lib.rs +++ b/crates/agentkeys-cli/src/lib.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; pub mod agent_admin; +pub mod cred_admin; pub mod device_session; pub mod hook; pub mod k11; diff --git a/crates/agentkeys-cli/src/main.rs b/crates/agentkeys-cli/src/main.rs index cb0f3839..cf340c38 100644 --- a/crates/agentkeys-cli/src/main.rs +++ b/crates/agentkeys-cli/src/main.rs @@ -392,6 +392,39 @@ enum Commands { #[command(subcommand)] action: AgentAction, }, + /// Credential fetch (#216) — the agent pulls its authorized `cred:` + /// from the vault to *use* it (e.g. its LLM key) at wire time. + Cred { + #[command(subcommand)] + action: CredAction, + }, +} + +#[derive(Subcommand)] +enum CredAction { + /// Fetch + decrypt a stored credential's secret (#216). Gated by the actor's + /// `cred:` scope; prints the plaintext to stdout. The agent's + /// identity/session come from the wire context (flags or env). + Fetch { + /// The credential service id (e.g. `openrouter`). + service: String, + #[arg(long, env = "AGENTKEYS_OPERATOR_OMNI")] + operator_omni: String, + #[arg(long, env = "AGENTKEYS_ACTOR_OMNI")] + actor_omni: String, + #[arg(long, env = "AGENTKEYS_DEVICE_KEY_HASH")] + device_key_hash: String, + #[arg(long, env = "AGENTKEYS_SESSION_BEARER")] + session_bearer: String, + #[arg(long, env = "AGENTKEYS_BROKER_URL")] + broker_url: String, + #[arg(long, env = "AGENTKEYS_WORKER_CRED_URL")] + cred_url: String, + #[arg(long, env = "VAULT_ROLE_ARN")] + vault_role_arn: String, + #[arg(long, env = "REGION", default_value = "us-east-1")] + region: String, + }, } #[derive(Subcommand)] @@ -1389,6 +1422,32 @@ async fn main() { session_bearer, } => agentkeys_cli::agent_admin::agent_pending(broker_url, session_bearer).await, }, + Commands::Cred { action } => match action { + CredAction::Fetch { + service, + operator_omni, + actor_omni, + device_key_hash, + session_bearer, + broker_url, + cred_url, + vault_role_arn, + region, + } => { + agentkeys_cli::cred_admin::cred_fetch( + service, + operator_omni, + actor_omni, + device_key_hash, + session_bearer, + broker_url, + cred_url, + vault_role_arn, + region, + ) + .await + } + }, }; match result { diff --git a/docs/operator-runbook-harness.md b/docs/operator-runbook-harness.md index d54fb39b..ac77e3f9 100644 --- a/docs/operator-runbook-harness.md +++ b/docs/operator-runbook-harness.md @@ -196,6 +196,7 @@ Steps 11-12 sign STS creds AS the agent → they need the agent's key. Three rol ### Other entry points - **`erc4337-master-e8.sh`** — standalone #164 mechanism smoke (passkey-only master mutation, green on mainnet). +- **`cred-fetch-demo.sh`** — **#216 agent-side vaulted-key fetch, real e2e.** A master vaults a probe credential via the daemon, then the agent fetches it back with `agentkeys cred fetch`, asserting the exact secret round-trips through the live cap-mint → STS → cred worker → decrypt chain. Idempotent (fixed `cred-e2e-probe`), `--ci`-tolerant, real-only. Run: `bash harness/cred-fetch-demo.sh`. - **`web-memory-bootstrap.sh`** — issue #196 web-memory pre-flight; runbook [`operator-runbook-web-memory.md`](operator-runbook-web-memory.md). - **`openviking-sandbox-setup.sh`** — *optional, advanced.* Stands up **OpenViking as the memory engine** (Model B) and runs **INSIDE the aiosandbox**, not on the Mac. It **requires the agent to diff --git a/harness/CLAUDE.md b/harness/CLAUDE.md index c1aff182..d26c179c 100644 --- a/harness/CLAUDE.md +++ b/harness/CLAUDE.md @@ -217,6 +217,7 @@ sandbox) is **GREEN**, never fail/incomplete. | `web-memory-bootstrap.sh` | issue #196 web-memory pre-flight + proof; runbook [`../docs/operator-runbook-web-memory.md`](../docs/operator-runbook-web-memory.md) | `--from/--to/--only-step` | | `memory-plant-demo.sh` | plant a proof memory archive through the REAL chain + read-back (the CLI/CI proof of the plant flow the web "⊕ plant prepared memory" button drives); **phase 4 of `v2-demo.sh`**. Plants into **dedicated `demo-*` namespaces** (never the real travel/personal/family) and **always deletes them on exit** (success OR failure, EXIT trap; `KEEP_DEMO_MEMORY=1` keeps), so test memory never leaks into the master's real store — the real prepared archive is planted ONLY by the user (the button), never by a demo or onboarding. Re-testable; idempotent (`--from 4.1`). | `--from-step/--only-step N` / `--ci` | | `web-parity-demo.sh` | **phase 6 of `v2-demo.sh`** (NOT a standalone front door) — boots `agentkeys-daemon --ui-bridge` SEEDED with the master's J1 + device via the `--ui-bridge-seed-*` daemon seam (skips re-onboarding) + plants a **dedicated `webparity` probe ns** through the **web** endpoint `POST /v1/master/memory/plant`, **deleted on exit** (success or failure). A 200 proves the daemon's chain (cap-mint → STS → worker → S3) == the agent/harness chain — the web↔harness drift gate. **Step 4 (#214)** additionally polls `GET /v1/agent/pairing/pending` and asserts a well-formed `{requests:[…]}` — the master-side web-pairing route reaches the real broker rendezvous (the full claim→register e2e needs a live §10.2 agent request, exercised agent-side). Reuses phases 1-2's build/chain/broker/master (one daemon boot, no re-bootstrap); real-only. | `--from-step/--only-step N` / `--ci` | +| `cred-fetch-demo.sh` | **#216 agent-side vaulted-key fetch, real e2e** (standalone). A master **vaults** a probe credential via the daemon (web path: cap-mint cred-store → STS → cred worker → S3), then the **agent** fetches it back with `agentkeys cred fetch` (CLI path: cap-mint cred-fetch → STS → cred worker → **decrypt**), asserting the EXACT secret round-trips. Proves the cred half of "the agent uses the key the master authorized it to use" (the Hermes wire is phase1-wire #216 Phase 4.0). Routes through the shared `agentkeys-backend-client` (no re-typed shapes, #204). Idempotent (a FIXED `cred-e2e-probe` service is overwritten each run — never accumulates); daemon killed on exit; real-only. | `--from-step/--only-step N` / `--ci` | (`scripts/setup-heima.sh` + `scripts/setup-broker-host.sh` are the canonical single-entry orchestrators for chain bring-up + the remote broker host; harness diff --git a/harness/cred-fetch-demo.sh b/harness/cred-fetch-demo.sh new file mode 100755 index 00000000..1a84d4f4 --- /dev/null +++ b/harness/cred-fetch-demo.sh @@ -0,0 +1,121 @@ +#!/usr/bin/env bash +# harness/cred-fetch-demo.sh — #216 agent-side vaulted-key fetch, real e2e. +# +# Proves the #216 guarantee against the LIVE broker + cred worker: a master +# VAULTS a credential (the web/daemon path — cap-mint cred-store → per-actor STS +# → cred worker → S3), and the AGENT FETCHES it back with `agentkeys cred fetch` +# (the agent/CLI path — cap-mint cred-fetch → STS → cred worker → decrypt), +# returning the EXACT secret. This is the cred half of "the agent uses the key +# the master authorized it to use" (the full Hermes wire is phase1-wire #216 +# Phase 4.0). The cred-fetch routes through the shared agentkeys-backend-client +# (no re-typed wire shapes, #204). +# +# Idempotent: a FIXED probe service (`cred-e2e-probe`) is overwritten each run +# (store = S3 PUT), so re-runs never accumulate vault objects; the daemon is +# killed on exit (EXIT trap). Real-only — needs a live broker + cred worker + +# a registered master; `--ci` tolerates missing infra (skip, exit 0). +# +# bash harness/cred-fetch-demo.sh # full +# bash harness/cred-fetch-demo.sh --only-step 4 # one step +# bash harness/cred-fetch-demo.sh --ci # tolerate missing infra +set -uo pipefail +set +m # quiet the "Terminated" job-control notice when the EXIT trap kills the daemon + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${ENV_FILE:-$REPO_ROOT/scripts/operator-workstation.env}" +[ -f "$ENV_FILE" ] && { set -a; . "$ENV_FILE"; set +a; } +# shellcheck source=/dev/null +. "$REPO_ROOT/harness/scripts/_lib.sh" + +CI=0; FROM=1; TO=99; STEP_TOTAL=4 +for a in "$@"; do case "$a" in + --ci) CI=1 ;; + --from-step) shift; FROM="${1:-1}" ;; --from-step=*) FROM="${a#*=}" ;; + --to-step) shift; TO="${1:-99}" ;; --to-step=*) TO="${a#*=}" ;; + --only-step) shift; FROM="${1:-1}"; TO="$FROM" ;; --only-step=*) FROM="${a#*=}"; TO="$FROM" ;; + --help|-h) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; +esac; done +{ [ -n "${AGENTKEYS_CI:-}" ] || { [ -n "${CI:-}" ] && [ "${CI}" != 0 ]; }; } && CI=1 +should_run() { [ "$1" -ge "$FROM" ] && [ "$1" -le "$TO" ]; } +c() { [ -t 2 ] && printf '\033[%sm%s\033[0m' "$1" "$2" || printf '%s' "$2"; } +step() { printf '\n%s %s\n' "$(c '1;36' "▸ step $1/$STEP_TOTAL")" "$2" >&2; } +ok() { printf ' %s %s\n' "$(c '1;32' ok)" "$1" >&2; } +skip() { printf ' %s %s\n' "$(c '1;33' skip)" "$1" >&2; } +die() { printf ' %s %s\n' "$(c '1;31' fail)" "$1" >&2; [ "$CI" = 1 ] && { skip "CI — tolerated"; exit 0; }; exit 1; } + +BROKER="${OIDC_ISSUER:-${AGENTKEYS_BROKER_URL:-}}" +CRED="${AGENTKEYS_WORKER_CRED_URL:-}" +REGION="${REGION:-us-east-1}" +VAULT_ROLE="${VAULT_ROLE_ARN:-}" +CLI_BIN="$REPO_ROOT/target/release/agentkeys" +DAEMON_BIN="$REPO_ROOT/target/release/agentkeys-daemon" +DPORT="${CRED_E2E_DAEMON_PORT:-3129}" +PROBE_SERVICE="cred-e2e-probe" # FIXED → re-runs overwrite, never accumulate +PROBE_SECRET="sk-cred-e2e-$$-$(date +%s)" # unique per run so the assert is fresh +DPID=""; DLOG="$(mktemp -t cred-e2e-daemon.XXXX)" +cleanup() { [ -n "$DPID" ] && kill "$DPID" 2>/dev/null; rm -f "$DLOG"; } +trap cleanup EXIT + +# ─── Step 1: prereqs + master identity + J1 ──────────────────────────────── +if should_run 1; then + step 1 "Prereqs + master identity + J1 (wallet SIWE)" + for t in cast jq curl; do command -v "$t" >/dev/null 2>&1 || die "missing $t"; done + [ -n "$BROKER" ] || { skip "no broker URL (OIDC_ISSUER) — cred-fetch is real-only"; [ "$CI" = 1 ] && exit 0 || die "no broker"; } + [ -n "$CRED" ] || die "no cred worker URL (AGENTKEYS_WORKER_CRED_URL)" + [ -n "$VAULT_ROLE" ] || die "no VAULT_ROLE_ARN" + for b in "$CLI_BIN" "$DAEMON_BIN"; do [ -x "$b" ] || { ( cd "$REPO_ROOT" && cargo build --release -p agentkeys-cli -p agentkeys-daemon ) || die "build failed"; break; }; done + KEY=$(resolve_master_key) || die "no master deployer key" + ADDR=$(cast wallet address --private-key "$KEY" | tr 'A-F' 'a-f') + OMNI=$(printf 'agentkeysevm%s' "$ADDR" | shasum -a 256 | awk '{print $1}') + DKH=$(resolve_active_master_dkh "$OMNI" "$ADDR" || true) + [ -n "$DKH" ] || die "master device not registered — run phases 1-2" + start=$(curl -sS --fail-with-body -X POST "$BROKER/v1/auth/wallet/start" -H 'content-type: application/json' -d "$(jq -n --arg a "$ADDR" '{address:$a, chain_id:1}')" 2>&1) || die "wallet/start: $start" + req=$(echo "$start" | jq -r '.request_id // empty'); msg=$(echo "$start" | jq -r '.siwe_message // empty') + [ -n "$req" ] || die "wallet/start gave no request_id: $start" + sig=$(cast wallet sign --private-key "$KEY" "$msg") + verify=$(curl -sS --fail-with-body -X POST "$BROKER/v1/auth/wallet/verify" -H 'content-type: application/json' -d "$(jq -n --arg r "$req" --arg s "$sig" '{request_id:$r, signature:$s}')" 2>&1) || die "wallet/verify: $verify" + J1=$(echo "$verify" | jq -r '.session_jwt // .jwt // empty') + [ -n "$J1" ] || die "no master J1: $verify" + ok "omni 0x${OMNI:0:12}… device ${DKH:0:12}… J1 len=${#J1}" +fi + +# ─── Step 2: boot the SEEDED daemon (the master web path) ────────────────── +if should_run 2; then + step 2 "Boot agentkeys-daemon --ui-bridge (seeded master, reads cred env)" + [ -n "${J1:-}" ] || die "no J1 — run step 1 first" + "$DAEMON_BIN" --ui-bridge \ + --ui-bridge-bind "127.0.0.1:$DPORT" --ui-bridge-origin "http://localhost:$DPORT" \ + --ui-bridge-rp-id localhost --ui-bridge-rp-name AgentKeys \ + --broker-url "$BROKER" --master-device-key-hash "$DKH" \ + --ui-bridge-seed-session-jwt "$J1" --ui-bridge-seed-omni "$OMNI" \ + > "$DLOG" 2>&1 & + DPID=$! + ready=0; for _ in $(seq 1 20); do curl -fsS "http://127.0.0.1:$DPORT/healthz" >/dev/null 2>&1 && { ready=1; break; }; kill -0 "$DPID" 2>/dev/null || break; sleep 0.5; done + [ "$ready" = 1 ] || die "daemon not ready: $(tail -3 "$DLOG" | tr '\n' ' ')" + ok "daemon up on http://127.0.0.1:$DPORT (seeded master session)" +fi + +# ─── Step 3: master VAULTS the probe cred (web path → real chain) ────────── +if should_run 3; then + step 3 "Master vaults '$PROBE_SERVICE' via the daemon (cap-mint cred-store → STS → cred worker → S3)" + { [ -n "${DPID:-}" ] && kill -0 "$DPID" 2>/dev/null; } || die "daemon not running — run step 2" + store=$(curl -sS --fail-with-body -X POST "http://127.0.0.1:$DPORT/v1/master/credentials/store" \ + -H 'content-type: application/json' -d "$(jq -n --arg s "$PROBE_SERVICE" --arg k "$PROBE_SECRET" '{service:$s, secret:$k}')" 2>&1) \ + || die "daemon vault failed (cred chain): $store" + echo "$store" | jq -e '.ok == true' >/dev/null 2>&1 || die "vault returned not-ok: $store" + ok "vaulted via the daemon — $(echo "$store" | jq -c '{ok,service,category}')" +fi + +# ─── Step 4: agent FETCHES it back via the CLI → assert round-trip ───────── +if should_run 4; then + step 4 "Agent: agentkeys cred fetch '$PROBE_SERVICE' → assert == the vaulted secret" + fetched=$("$CLI_BIN" cred fetch "$PROBE_SERVICE" \ + --operator-omni "0x$OMNI" --actor-omni "0x$OMNI" --device-key-hash "$DKH" \ + --session-bearer "$J1" --broker-url "$BROKER" --cred-url "$CRED" \ + --vault-role-arn "$VAULT_ROLE" --region "$REGION" 2>&1) \ + || die "cred fetch errored: $fetched" + [ "$fetched" = "$PROBE_SECRET" ] || die "round-trip mismatch — fetched '${fetched:0:24}…' want '${PROBE_SECRET:0:24}…'" + ok "agent cred-fetch returned the EXACT vaulted secret (len=${#fetched}) — #216 cred half verified" +fi + +printf '\n%s the agent fetched the credential the master vaulted — through the real cap-mint → STS → cred worker → decrypt chain.\n' "$(c '1;32' 'DONE ·')" >&2 From 5b8ebbddb4b42890aa65da5437d04ef99d0ccea1 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 02:59:11 +0800 Subject: [PATCH 02/85] =?UTF-8?q?feat:=20#216=20cred-wire-demo.sh=20?= =?UTF-8?q?=E2=80=94=20the=20FULL=20agent-side=20wire=20e2e=20(VERIFIED=20?= =?UTF-8?q?live,=20real=20LLM)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Carries the #216 cred-fetch through the Hermes wire — the complete agent-side guarantee, proven end-to-end against the LIVE broker + cred worker + aiosandbox: master VAULTS the LLM key (daemon: cap-mint cred-store → STS → cred worker → S3) → agent CRED-FETCHES it (agentkeys cred fetch: cap-mint cred-fetch → STS → decrypt) → plant into Hermes (~/.hermes/.env + hermes config set model.*) IN THE SANDBOX → Hermes RUNS on the vault key (real LLM smoke) — NO OPENROUTER_API_KEY in the agent env harness/cred-wire-demo.sh (STEP_TOTAL=6, contract-compliant, headless): asserts the key Hermes uses == the master-vaulted key (sha), and that it arrived via the vault fetch, not an ambient env var (the sandbox shell has no OPENROUTER_API_KEY; the .env value is the cred-fetch result). The durable, no-Touch-ID complement to phase1-wire-demo.sh Phase 4.0b — same wire result without the interactive gates. Routes through the shared agentkeys-backend-client (#204). VERIFIED LIVE (this run, real OpenRouter key): step 4 ok agent fetched the vaulted key from the vault (len=73, sha fddff3ff…) — no env read step 5 ok planted the vault-fetched key into ~/.hermes/.env + hermes config step 6 ok 6.1 vault-sourced — the key Hermes will use == the master-vaulted key, NOT an env var step 6 ok 6.2 llm smoke — Hermes answered using the VAULT-FETCHED key: "OK" Exit 0. A REAL deepseek-v4-flash call via OpenRouter answered "OK" on the vault-fetched key — #216's acceptance ("the agent runs on MY authorized key, not the operator's env") proven with real data. Idempotent (FIXED openrouter service; the .env key-line is rewritten not appended); daemon killed on exit; --ci-tolerant. keep-docs-in-sync: harness/CLAUDE.md + docs/operator-runbook-harness.md. --- docs/operator-runbook-harness.md | 1 + harness/CLAUDE.md | 1 + harness/cred-wire-demo.sh | 193 +++++++++++++++++++++++++++++++ 3 files changed, 195 insertions(+) create mode 100755 harness/cred-wire-demo.sh diff --git a/docs/operator-runbook-harness.md b/docs/operator-runbook-harness.md index ac77e3f9..64ab39a9 100644 --- a/docs/operator-runbook-harness.md +++ b/docs/operator-runbook-harness.md @@ -197,6 +197,7 @@ Steps 11-12 sign STS creds AS the agent → they need the agent's key. Three rol - **`erc4337-master-e8.sh`** — standalone #164 mechanism smoke (passkey-only master mutation, green on mainnet). - **`cred-fetch-demo.sh`** — **#216 agent-side vaulted-key fetch, real e2e.** A master vaults a probe credential via the daemon, then the agent fetches it back with `agentkeys cred fetch`, asserting the exact secret round-trips through the live cap-mint → STS → cred worker → decrypt chain. Idempotent (fixed `cred-e2e-probe`), `--ci`-tolerant, real-only. Run: `bash harness/cred-fetch-demo.sh`. +- **`cred-wire-demo.sh`** — **#216 agent-side wire, the FULL e2e.** Carries the cred-fetch through the Hermes wire: the master vaults the LLM key, the agent **cred-fetches** it, the harness **plants it into the sandbox Hermes** (`~/.hermes/.env` + `hermes config`), and **Hermes runs on the vault key** (real LLM smoke) — asserting the planted key == the vaulted key with **no `OPENROUTER_API_KEY` in the agent env**. The durable, no-Touch-ID complement to `phase1-wire-demo.sh` Phase 4.0b. Needs a reachable aiosandbox (`SANDBOX_URL`, default `http://localhost:8080`) with Hermes installed; the seed LLM key comes from `$OPENROUTER_API_KEY`/`$LLM_API_KEY` (skips the real-LLM smoke + vaults a probe if absent). `--ci`-tolerant, real-only. Run: `bash harness/cred-wire-demo.sh`. - **`web-memory-bootstrap.sh`** — issue #196 web-memory pre-flight; runbook [`operator-runbook-web-memory.md`](operator-runbook-web-memory.md). - **`openviking-sandbox-setup.sh`** — *optional, advanced.* Stands up **OpenViking as the memory engine** (Model B) and runs **INSIDE the aiosandbox**, not on the Mac. It **requires the agent to diff --git a/harness/CLAUDE.md b/harness/CLAUDE.md index d26c179c..66d023e7 100644 --- a/harness/CLAUDE.md +++ b/harness/CLAUDE.md @@ -218,6 +218,7 @@ sandbox) is **GREEN**, never fail/incomplete. | `memory-plant-demo.sh` | plant a proof memory archive through the REAL chain + read-back (the CLI/CI proof of the plant flow the web "⊕ plant prepared memory" button drives); **phase 4 of `v2-demo.sh`**. Plants into **dedicated `demo-*` namespaces** (never the real travel/personal/family) and **always deletes them on exit** (success OR failure, EXIT trap; `KEEP_DEMO_MEMORY=1` keeps), so test memory never leaks into the master's real store — the real prepared archive is planted ONLY by the user (the button), never by a demo or onboarding. Re-testable; idempotent (`--from 4.1`). | `--from-step/--only-step N` / `--ci` | | `web-parity-demo.sh` | **phase 6 of `v2-demo.sh`** (NOT a standalone front door) — boots `agentkeys-daemon --ui-bridge` SEEDED with the master's J1 + device via the `--ui-bridge-seed-*` daemon seam (skips re-onboarding) + plants a **dedicated `webparity` probe ns** through the **web** endpoint `POST /v1/master/memory/plant`, **deleted on exit** (success or failure). A 200 proves the daemon's chain (cap-mint → STS → worker → S3) == the agent/harness chain — the web↔harness drift gate. **Step 4 (#214)** additionally polls `GET /v1/agent/pairing/pending` and asserts a well-formed `{requests:[…]}` — the master-side web-pairing route reaches the real broker rendezvous (the full claim→register e2e needs a live §10.2 agent request, exercised agent-side). Reuses phases 1-2's build/chain/broker/master (one daemon boot, no re-bootstrap); real-only. | `--from-step/--only-step N` / `--ci` | | `cred-fetch-demo.sh` | **#216 agent-side vaulted-key fetch, real e2e** (standalone). A master **vaults** a probe credential via the daemon (web path: cap-mint cred-store → STS → cred worker → S3), then the **agent** fetches it back with `agentkeys cred fetch` (CLI path: cap-mint cred-fetch → STS → cred worker → **decrypt**), asserting the EXACT secret round-trips. Proves the cred half of "the agent uses the key the master authorized it to use" (the Hermes wire is phase1-wire #216 Phase 4.0). Routes through the shared `agentkeys-backend-client` (no re-typed shapes, #204). Idempotent (a FIXED `cred-e2e-probe` service is overwritten each run — never accumulates); daemon killed on exit; real-only. | `--from-step/--only-step N` / `--ci` | +| `cred-wire-demo.sh` | **#216 agent-side wire, the FULL e2e** (standalone, headless). Extends `cred-fetch-demo.sh` through the Hermes wire: master vaults the LLM key → **agent cred-fetches it** → **plant into the sandbox Hermes** (`~/.hermes/.env` + `hermes config set model.*`) → **Hermes runs on the vault key** (real LLM smoke), asserting the planted key == the vaulted key (sha) with **no `OPENROUTER_API_KEY` in the agent env**. The durable, no-Touch-ID complement to `phase1-wire-demo.sh` Phase 4.0b. Needs a reachable aiosandbox (`SANDBOX_URL`, default `:8080`) with Hermes installed. Idempotent (FIXED `openrouter` service; `.env` key-line rewritten not appended); daemon killed on exit; real-only. | `--from-step/--only-step N` / `--ci` | (`scripts/setup-heima.sh` + `scripts/setup-broker-host.sh` are the canonical single-entry orchestrators for chain bring-up + the remote broker host; harness diff --git a/harness/cred-wire-demo.sh b/harness/cred-wire-demo.sh new file mode 100755 index 00000000..c493f651 --- /dev/null +++ b/harness/cred-wire-demo.sh @@ -0,0 +1,193 @@ +#!/usr/bin/env bash +# harness/cred-wire-demo.sh — #216 agent-side wire, the FULL e2e. +# +# Proves the #216 guarantee against the LIVE broker + cred worker + aiosandbox: +# the agent runs Hermes on an LLM key it FETCHED FROM THE MASTER'S VAULT — never +# an ambient `OPENROUTER_API_KEY` in the agent's env. The chain end-to-end: +# +# master VAULTS the LLM key (daemon web path: cap-mint cred-store → STS → cred worker → S3) +# → agent CRED-FETCHES it (agentkeys cred fetch: cap-mint cred-fetch → STS → cred worker → decrypt) +# → wire plants it into Hermes (~/.hermes/.env + hermes config set model.*) IN THE SANDBOX +# → Hermes runs on the vault key (real LLM smoke) — with NO OPENROUTER_API_KEY in the sandbox env +# +# This is the durable, headless complement to phase1-wire-demo.sh Phase 4.0b (the +# operator-interactive surprise): same wire result (the vault-fetched key in +# Hermes), proven without Touch ID / manual gates. The fetch routes through the +# shared agentkeys-backend-client (no re-typed wire shapes, #204). +# +# Real-only: needs a live broker + cred worker + a registered master + a reachable +# aiosandbox with Hermes. `--ci` tolerates missing infra (skip, exit 0). The seed +# LLM key comes from $LLM_API_KEY / $OPENROUTER_API_KEY (the master's key — it +# legitimately HAS the key; the POINT is the AGENT reads it from the vault, not the +# env). With no seed key a probe is vaulted + the real-LLM smoke is skipped (the +# cred → plant chain is still proven). +# +# Idempotent: a FIXED vault service (default `openrouter`) is overwritten each run; +# the sandbox ~/.hermes/.env OPENROUTER_API_KEY line is rewritten (never appended); +# the daemon is killed on exit (EXIT trap). +# +# bash harness/cred-wire-demo.sh # full +# bash harness/cred-wire-demo.sh --only-step 5 # one step +# bash harness/cred-wire-demo.sh --ci # tolerate missing infra +set -uo pipefail +set +m # quiet the "Terminated" job-control notice when the EXIT trap kills the daemon + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${ENV_FILE:-$REPO_ROOT/scripts/operator-workstation.env}" +[ -f "$ENV_FILE" ] && { set -a; . "$ENV_FILE"; set +a; } +# shellcheck source=/dev/null +. "$REPO_ROOT/harness/scripts/_lib.sh" + +CI=0; FROM=1; TO=99; STEP_TOTAL=6 +for a in "$@"; do case "$a" in + --ci) CI=1 ;; + --from-step) shift; FROM="${1:-1}" ;; --from-step=*) FROM="${a#*=}" ;; + --to-step) shift; TO="${1:-99}" ;; --to-step=*) TO="${a#*=}" ;; + --only-step) shift; FROM="${1:-1}"; TO="$FROM" ;; --only-step=*) FROM="${a#*=}"; TO="$FROM" ;; + --help|-h) sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; +esac; done +{ [ -n "${AGENTKEYS_CI:-}" ] || { [ -n "${CI:-}" ] && [ "${CI}" != 0 ]; }; } && CI=1 +should_run() { [ "$1" -ge "$FROM" ] && [ "$1" -le "$TO" ]; } +c() { [ -t 2 ] && printf '\033[%sm%s\033[0m' "$1" "$2" || printf '%s' "$2"; } +step() { printf '\n%s %s\n' "$(c '1;36' "▸ step $1/$STEP_TOTAL")" "$2" >&2; } +ok() { printf ' %s %s\n' "$(c '1;32' ok)" "$1" >&2; } +skip() { printf ' %s %s\n' "$(c '1;33' skip)" "$1" >&2; } +die() { printf ' %s %s\n' "$(c '1;31' fail)" "$1" >&2; [ "$CI" = 1 ] && { skip "CI — tolerated"; exit 0; }; exit 1; } + +BROKER="${OIDC_ISSUER:-${AGENTKEYS_BROKER_URL:-}}" +CRED="${AGENTKEYS_WORKER_CRED_URL:-}" +REGION="${REGION:-us-east-1}" +VAULT_ROLE="${VAULT_ROLE_ARN:-}" +SANDBOX_URL="${SANDBOX_URL:-http://localhost:8080}" +SERVICE="${SERVICE:-openrouter}" # the vault cred service (= the LLM provider) +LLM_API_KEY="${LLM_API_KEY:-${OPENROUTER_API_KEY:-}}" # master's key to SEED the vault (agent reads it back via cap) +LLM_BASE_URL="${LLM_BASE_URL:-https://openrouter.ai/api/v1}" +LLM_MODEL="${LLM_MODEL:-deepseek/deepseek-v4-flash}" +CLI_BIN="$REPO_ROOT/target/release/agentkeys" +DAEMON_BIN="$REPO_ROOT/target/release/agentkeys-daemon" +DPORT="${CRED_WIRE_DAEMON_PORT:-3130}" +DPID=""; DLOG="$(mktemp -t cred-wire-daemon.XXXX)" +cleanup() { [ -n "$DPID" ] && kill "$DPID" 2>/dev/null; rm -f "$DLOG"; } +trap cleanup EXIT + +# sandbox drive (the agent host) — same mechanism as phase1-wire-demo.sh::sbx_exec. +sbx_exec() { curl -sS --max-time "${SBX_EXEC_MAXTIME:-120}" -X POST "$SANDBOX_URL/v1/shell/exec" \ + -H 'content-type: application/json' -d "$(jq -n --arg c "$1" '{command:$c}')" \ + | jq -r '.data.output // ""'; } +sbx_rc() { curl -sS --max-time "${SBX_EXEC_MAXTIME:-120}" -X POST "$SANDBOX_URL/v1/shell/exec" \ + -H 'content-type: application/json' -d "$(jq -n --arg c "$1" '{command:$c}')" \ + | jq -r '.data.exit_code // 1'; } +sha() { printf '%s' "$1" | shasum -a 256 | awk '{print $1}'; } + +# ─── Step 1: prereqs + master identity + J1 ──────────────────────────────── +if should_run 1; then + step 1 "Prereqs + master identity + J1 (wallet SIWE) + sandbox reachable" + for t in cast jq curl; do command -v "$t" >/dev/null 2>&1 || die "missing $t"; done + [ -n "$BROKER" ] || { skip "no broker URL (OIDC_ISSUER) — wire is real-only"; [ "$CI" = 1 ] && exit 0 || die "no broker"; } + [ -n "$CRED" ] || die "no cred worker URL (AGENTKEYS_WORKER_CRED_URL)" + [ -n "$VAULT_ROLE" ] || die "no VAULT_ROLE_ARN" + for b in "$CLI_BIN" "$DAEMON_BIN"; do [ -x "$b" ] || { ( cd "$REPO_ROOT" && cargo build --release -p agentkeys-cli -p agentkeys-daemon ) || die "build failed"; break; }; done + [ "$(sbx_rc 'echo ok')" = "0" ] || { skip "sandbox $SANDBOX_URL not reachable — start aiosandbox"; [ "$CI" = 1 ] && exit 0 || die "no sandbox"; } + [ "$(sbx_rc 'export PATH=$HOME/.local/bin:$PATH; command -v hermes')" = "0" ] || die "hermes not installed in the sandbox (run phase1-wire-demo.sh Phase 1 first)" + KEY=$(resolve_master_key) || die "no master deployer key" + ADDR=$(cast wallet address --private-key "$KEY" | tr 'A-F' 'a-f') + OMNI=$(printf 'agentkeysevm%s' "$ADDR" | shasum -a 256 | awk '{print $1}') + DKH=$(resolve_active_master_dkh "$OMNI" "$ADDR" || true) + [ -n "$DKH" ] || die "master device not registered — run phases 1-2" + start=$(curl -sS --fail-with-body -X POST "$BROKER/v1/auth/wallet/start" -H 'content-type: application/json' -d "$(jq -n --arg a "$ADDR" '{address:$a, chain_id:1}')" 2>&1) || die "wallet/start: $start" + req=$(echo "$start" | jq -r '.request_id // empty'); msg=$(echo "$start" | jq -r '.siwe_message // empty') + [ -n "$req" ] || die "wallet/start gave no request_id: $start" + sig=$(cast wallet sign --private-key "$KEY" "$msg") + verify=$(curl -sS --fail-with-body -X POST "$BROKER/v1/auth/wallet/verify" -H 'content-type: application/json' -d "$(jq -n --arg r "$req" --arg s "$sig" '{request_id:$r, signature:$s}')" 2>&1) || die "wallet/verify: $verify" + J1=$(echo "$verify" | jq -r '.session_jwt // .jwt // empty') + [ -n "$J1" ] || die "no master J1: $verify" + ok "omni 0x${OMNI:0:12}… device ${DKH:0:12}… J1 len=${#J1} sandbox+hermes ready" +fi + +# ─── Step 2: boot the SEEDED daemon (the master web path) ────────────────── +if should_run 2; then + step 2 "Boot agentkeys-daemon --ui-bridge (seeded master, reads cred env)" + [ -n "${J1:-}" ] || die "no J1 — run step 1 first" + "$DAEMON_BIN" --ui-bridge \ + --ui-bridge-bind "127.0.0.1:$DPORT" --ui-bridge-origin "http://localhost:$DPORT" \ + --ui-bridge-rp-id localhost --ui-bridge-rp-name AgentKeys \ + --broker-url "$BROKER" --master-device-key-hash "$DKH" \ + --ui-bridge-seed-session-jwt "$J1" --ui-bridge-seed-omni "$OMNI" \ + > "$DLOG" 2>&1 & + DPID=$! + ready=0; for _ in $(seq 1 20); do curl -fsS "http://127.0.0.1:$DPORT/healthz" >/dev/null 2>&1 && { ready=1; break; }; kill -0 "$DPID" 2>/dev/null || break; sleep 0.5; done + [ "$ready" = 1 ] || die "daemon not ready: $(tail -3 "$DLOG" | tr '\n' ' ')" + ok "daemon up on http://127.0.0.1:$DPORT (seeded master session)" +fi + +# ─── Step 3: master VAULTS the LLM key (the master legitimately HAS it) ───── +if should_run 3; then + step 3 "Master vaults '$SERVICE' (the LLM key) via the daemon → the agent will fetch it back" + { [ -n "${DPID:-}" ] && kill -0 "$DPID" 2>/dev/null; } || die "daemon not running — run step 2" + if [ -n "$LLM_API_KEY" ]; then + SEED_KEY="$LLM_API_KEY"; SEED_REAL=1 + else + SEED_KEY="sk-cred-wire-probe-$$-$(date +%s)"; SEED_REAL=0 + skip "3.0 seed key" "no \$OPENROUTER_API_KEY/\$LLM_API_KEY — vaulting a PROBE (cred→plant chain proven; real-LLM smoke skipped)" + fi + store=$(curl -sS --fail-with-body -X POST "http://127.0.0.1:$DPORT/v1/master/credentials/store" \ + -H 'content-type: application/json' -d "$(jq -n --arg s "$SERVICE" --arg k "$SEED_KEY" '{service:$s, secret:$k}')" 2>&1) \ + || die "daemon vault failed (cred chain): $store" + echo "$store" | jq -e '.ok == true' >/dev/null 2>&1 || die "vault returned not-ok: $store" + ok "vaulted '$SERVICE' (real=$SEED_REAL) — $(echo "$store" | jq -c '{ok,service,category}')" +fi + +# ─── Step 4: agent CRED-FETCHES the key → assert == what the master vaulted ─ +if should_run 4; then + step 4 "Agent: agentkeys cred fetch '$SERVICE' → assert == the vaulted LLM key" + [ -n "${SEED_KEY:-}" ] || die "no seed key — run step 3" + FETCHED=$("$CLI_BIN" cred fetch "$SERVICE" \ + --operator-omni "0x$OMNI" --actor-omni "0x$OMNI" --device-key-hash "$DKH" \ + --session-bearer "$J1" --broker-url "$BROKER" --cred-url "$CRED" \ + --vault-role-arn "$VAULT_ROLE" --region "$REGION" 2>&1) \ + || die "cred fetch errored: $FETCHED" + [ "$(sha "$FETCHED")" = "$(sha "$SEED_KEY")" ] || die "vault round-trip mismatch (fetched ≠ vaulted)" + ok "agent fetched the vaulted key from the vault (len=${#FETCHED}, sha $(sha "$FETCHED" | cut -c1-12)…) — no env read" +fi + +# ─── Step 5: WIRE — plant the FETCHED key into the sandbox Hermes ─────────── +if should_run 5; then + step 5 "Wire: plant the vault-fetched key into the sandbox Hermes (NO OPENROUTER_API_KEY in the agent env)" + [ -n "${FETCHED:-}" ] || die "no fetched key — run step 4" + # Prove the value arrives via the vault, not an ambient env: strip any existing + # OPENROUTER_API_KEY from the sandbox .env, confirm gone, THEN plant the fetched + # value. (Hermes reads the provider key from ~/.hermes/.env.) + env_path='$HOME/.hermes/.env' + sbx_exec "mkdir -p \$HOME/.hermes; ENV=$env_path; touch \"\$ENV\"; grep -v '^OPENROUTER_API_KEY=' \"\$ENV\" > \"\$ENV.tmp\" 2>/dev/null; mv \"\$ENV.tmp\" \"\$ENV\"" >/dev/null + [ "$(sbx_rc "grep -q '^OPENROUTER_API_KEY=' $env_path")" != "0" ] || die "could not strip pre-existing OPENROUTER_API_KEY from the sandbox .env" + sbx_exec "ENV=$env_path; printf 'OPENROUTER_API_KEY=%s\n' $(printf '%q' "$FETCHED") >> \"\$ENV\"" >/dev/null + [ "$(sbx_rc "grep -q '^OPENROUTER_API_KEY=' $env_path")" = "0" ] || die "could not write the fetched key to the sandbox ~/.hermes/.env" + sbx_exec "export PATH=\$HOME/.local/bin:\$PATH; hermes config set model.provider openrouter >/dev/null 2>&1; hermes config set model.base_url $(printf '%q' "$LLM_BASE_URL") >/dev/null 2>&1; hermes config set model.default $(printf '%q' "$LLM_MODEL") >/dev/null 2>&1" >/dev/null + ok "planted the vault-fetched key into ~/.hermes/.env + hermes config (provider=openrouter, model=$LLM_MODEL)" +fi + +# ─── Step 6: PROOF — Hermes runs on the vault key (value match + real smoke) ─ +if should_run 6; then + step 6 "Proof: the key Hermes uses == the vaulted key (vault-sourced), + a real LLM smoke" + [ -n "${FETCHED:-}" ] || die "no fetched key — run step 4" + planted_sha=$(sbx_exec "v=\$(grep '^OPENROUTER_API_KEY=' \$HOME/.hermes/.env | head -1 | sed 's/^OPENROUTER_API_KEY=//'); printf '%s' \"\$v\" | shasum -a 256 | awk '{print \$1}'") + [ "$planted_sha" = "$(sha "$FETCHED")" ] || die "the key in the sandbox Hermes ≠ the vault-fetched key (planted sha ${planted_sha:0:12}…)" + ok "6.1 vault-sourced — the key Hermes will use == the master-vaulted key (sha ${planted_sha:0:12}…), NOT an env var" + if [ "${SEED_REAL:-0}" = "1" ]; then + smoke=$(sbx_exec "export PATH=\$HOME/.local/bin:\$PATH; cd \$HOME; timeout 55 hermes -z 'Reply with exactly: OK' 2>&1 | tail -3") + smoke1=$(echo "$smoke" | tr '\n' ' ' | sed 's/ */ /g' | cut -c1-80) + if echo "$smoke" | grep -q '429'; then + skip "6.2 llm smoke — $LLM_MODEL is HTTP 429 (rate-limited); the key works, the model is throttled — vault-source proof (6.1) stands" + elif echo "$smoke" | grep -qiE 'unauthorized|invalid.key|no inference|forbidden|401|403'; then + die "6.2 llm smoke — Hermes REJECTED the vault key: $smoke1" + elif [ -n "$(echo "$smoke" | tr -d '[:space:]')" ]; then + ok "6.2 llm smoke — Hermes answered using the VAULT-FETCHED key: \"$smoke1\"" + else + skip "6.2 llm smoke — empty response (sandbox egress?) — 6.1 (vault-source) is authoritative" + fi + else + skip "6.2 llm smoke — probe key (no real LLM) — 6.1 proved the vault→fetch→plant chain" + fi +fi + +printf '\n%s the agent runs Hermes on a key it FETCHED FROM THE MASTER VAULT — cap-mint → STS → cred worker → decrypt → ~/.hermes/.env. No ambient OPENROUTER_API_KEY.\n' "$(c '1;32' 'DONE ·')" >&2 From c7e3922273c0286b7116141e7033c2df6c0a1f87 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 03:03:55 +0800 Subject: [PATCH 03/85] =?UTF-8?q?feat:=20#216=20phase1-wire=20Phase=204.0b?= =?UTF-8?q?=20=E2=80=94=20plant=20the=20VAULT-fetched=20key=20(env=20?= =?UTF-8?q?=E2=86=92=20dev=20fallback)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the operator-env-key write (#216's named target: phase1-wire-demo.sh:1072) with the vault path: Phase 4.0b now fetches the agent's LLM key from the master's VAULT via `agentkeys cred fetch cred:` and plants THAT into the sandbox Hermes — the $OPENROUTER_API_KEY/$LLM_API_KEY env becomes a clearly-labelled DEV-ONLY fallback. - Phase 4.0b resolves WIRE_KEY VAULT-FIRST (the agent-identity cred-fetch: operator session authorizes, actor=agent device — mirrors the memory cap-mint identity model), env-fallback only when the vault is unavailable. Backward-compatible: with no vaulted key / no cred scope the fetch fails and it degrades to the env key exactly as before, so the change is fallback-safe. - SEED_SCOPE_SERVICES also grants the agent its cred scope (bare `$SERVICE` — the cred-fetch cap-mint hashes the bare service, unlike memory's `memory:`) so the P.3 pairing grant authorizes the vault fetch. - Honest labelling throughout: the 0.6 step, the header, and the top overview now state the env key is the dev fallback and the vault is primary; the 4.0 ok line prints which source the planted key came from. The full vault chain (master vaults → agent cred-fetches → plant → Hermes runs on it, real LLM smoke) is proven headless + live by harness/cred-wire-demo.sh (this PR). The interactive agent-identity path additionally needs the operator's Touch ID cred-scope grant (P.3) + a seeded vault — until then Phase 4.0b labels + uses the dev fallback. --- harness/phase1-wire-demo.sh | 69 ++++++++++++++++++++++++++++--------- 1 file changed, 53 insertions(+), 16 deletions(-) diff --git a/harness/phase1-wire-demo.sh b/harness/phase1-wire-demo.sh index 54bd1bb5..85f735e3 100755 --- a/harness/phase1-wire-demo.sh +++ b/harness/phase1-wire-demo.sh @@ -17,10 +17,10 @@ # harness cross-builds it in an arm64 Linux rust container and uploads it via # the sandbox's own file API (no scp). # -# Manual gates (the "test through" essence): the LLM key (auto from -# $OPENROUTER_API_KEY, else paste), real Touch ID at scope grant (only if not -# already scoped), the Hermes surprise + its confirmation. Everything else is -# automated. +# Manual gates (the "test through" essence): the LLM key (#216: Phase 4.0b fetches +# it from the master's VAULT — cred:; $OPENROUTER_API_KEY/paste is only a +# dev fallback), real Touch ID at scope grant (only if not already scoped), the +# Hermes surprise + its confirmation. Everything else is automated. # # Usage: # bash harness/phase1-wire-demo.sh [--real] [--webauthn] [--unwire] @@ -41,9 +41,10 @@ MCP_URL_IN_SANDBOX="http://localhost:${MCP_PORT}/mcp" SESSION_ID="${SESSION_ID:-alice}" # master session label on the Mac AGENT_LABEL="${AGENT_LABEL:-demo-agent}" SERVICE="${SERVICE:-openrouter}" # LLM cred service name -# LLM key for the Phase 4 Hermes "surprise". Falls back to OPENROUTER_API_KEY -# (export it in ~/.zshenv) so 0.6 needs no manual paste. Used to configure the -# sandbox Hermes model before the surprise chat. +# #216 DEV-FALLBACK LLM key. Phase 4.0b configures the sandbox Hermes model from +# the key the agent FETCHES FROM THE MASTER'S VAULT (cred:$SERVICE); this env var +# (export OPENROUTER_API_KEY in ~/.zshenv) is used ONLY when the vault fetch is +# unavailable. The full vault path is proven by harness/cred-wire-demo.sh. LLM_API_KEY="${LLM_API_KEY:-${OPENROUTER_API_KEY:-}}" LLM_BASE_URL="${LLM_BASE_URL:-https://openrouter.ai/api/v1}" LLM_MODEL="${LLM_MODEL:-deepseek/deepseek-v4-flash}" # OpenRouter slug; ':free' tier is 429-throttled @@ -66,6 +67,13 @@ if [[ -z "${SEED_SCOPE_SERVICES:-}" ]]; then SEED_SCOPE_SERVICES="" IFS=',' read -ra _seed_ns <<<"$MEMORY_NS" for _n in "${_seed_ns[@]}"; do SEED_SCOPE_SERVICES+="${SEED_SCOPE_SERVICES:+,}memory:$_n"; done + # #216: also authorize the agent for its LLM cred so Phase 4.0b can fetch the + # key from the master's vault (the agent-identity cred-fetch cap-mint checks + # isServiceInScope(operator, actor, keccak("$SERVICE")) — the grant service is + # the BARE cred name, NOT prefixed, since cred-fetch requests the bare service + # (unlike memory's "memory:"). Without this grant the Phase 4.0b vault fetch + # → service_not_in_scope and falls back to the operator env (dev only). + SEED_SCOPE_SERVICES+="${SEED_SCOPE_SERVICES:+,}$SERVICE" fi ENV_FILE="${ENV_FILE:-$REPO_ROOT/scripts/operator-workstation.env}" AGENT_FILE="${AGENT_FILE:-$HOME/.agentkeys/agents/${AGENT_LABEL}.json}" @@ -334,14 +342,17 @@ phase0_prereqs() { ok "0.5 scope" "verify via heima-scope-set.sh; grant needs real Touch ID if absent" fi - # 0.6 LLM key — env fallback (OPENROUTER_API_KEY / LLM_API_KEY) → manual paste. + # 0.6 LLM key — the #216 DEV FALLBACK only. Phase 4.0b fetches the agent's key + # from the MASTER'S VAULT (cred:$SERVICE) first; this env/paste value is used + # solely when the vault fetch is unavailable. (The real vault path is proven by + # harness/cred-wire-demo.sh.) if [[ -n "$LLM_API_KEY" ]]; then - ok "0.6 LLM key" "from OPENROUTER_API_KEY/LLM_API_KEY env (${#LLM_API_KEY} chars)" + ok "0.6 LLM key" "dev-fallback from OPENROUTER_API_KEY/LLM_API_KEY env (${#LLM_API_KEY} chars) — Phase 4.0b prefers the vault" else - gate "0.6 LLM key" "no OPENROUTER_API_KEY in env (export it in ~/.zshenv) — paste an LLM key now, or just press enter to skip the Phase 4 surprise" secret || true + gate "0.6 LLM key" "no OPENROUTER_API_KEY in env (export it in ~/.zshenv) — paste a DEV-fallback LLM key now (Phase 4.0b prefers the vault), or press enter to rely on the vault / skip the surprise" secret || true [[ -n "${REPLY:-}" ]] && LLM_API_KEY="$REPLY" - if [[ -n "$LLM_API_KEY" ]]; then ok "0.6 LLM key" "operator-provided (${#LLM_API_KEY} chars)" - else skip "0.6 LLM key" "none provided — Phase 4 surprise will be skipped"; fi + if [[ -n "$LLM_API_KEY" ]]; then ok "0.6 LLM key" "dev-fallback operator-provided (${#LLM_API_KEY} chars)" + else skip "0.6 LLM key" "no dev fallback — Phase 4.0b will rely on the vault cred:$SERVICE (else skip the surprise)"; fi fi # 0.7 session bearer — must be a FRESH JWT whose agentkeys.omni_account == @@ -1046,8 +1057,34 @@ phase4_surprise() { skip_phase 4 && { log "Phase 4 — surprise: skip (--skip-4)"; return; } log "Phase 4 — the surprise (real Hermes session in the sandbox)" - if [[ -z "$LLM_API_KEY" ]]; then - skip "4.0 hermes llm" "no LLM key (export OPENROUTER_API_KEY) — skipping the surprise" + # 4.0 #216: the agent's LLM key comes from the MASTER'S VAULT (cred-fetch via its + # authorized cred scope), NOT an ambient operator env. Resolve VAULT-FIRST; the + # $OPENROUTER_API_KEY/$LLM_API_KEY env is a DEV-ONLY fallback (clearly labelled). + # The full vault chain is proven headless (master-self) by harness/cred-wire-demo.sh; + # the agent-identity fetch here additionally needs (a) the cred scope granted at + # pairing (P.3 SEED_SCOPE_SERVICES, --webauthn) and (b) the key already vaulted. + local WIRE_KEY="" WIRE_KEY_SRC="" _host_cli="" + if [[ -x "$REPO_ROOT/target/release/agentkeys" ]]; then _host_cli="$REPO_ROOT/target/release/agentkeys" + elif [[ -x "$REPO_ROOT/target/debug/agentkeys" ]]; then _host_cli="$REPO_ROOT/target/debug/agentkeys" + else _host_cli="$(command -v agentkeys 2>/dev/null || true)"; fi + if [[ -n "$_host_cli" && -n "${AGENTKEYS_WORKER_CRED_URL:-}" && -n "${VAULT_ROLE_ARN:-}" \ + && -n "$SESSION_BEARER" && -n "$ACTOR_OMNI" && -n "$OPERATOR_OMNI" && -n "$DEVICE_KEY_HASH" ]]; then + local _fetched + if _fetched="$("$_host_cli" cred fetch "$SERVICE" \ + --operator-omni "$OPERATOR_OMNI" --actor-omni "$ACTOR_OMNI" \ + --device-key-hash "$DEVICE_KEY_HASH" --session-bearer "$SESSION_BEARER" \ + --broker-url "${BROKER_URL%/}" --cred-url "${AGENTKEYS_WORKER_CRED_URL}" \ + --vault-role-arn "${VAULT_ROLE_ARN}" --region "${REGION:-us-east-1}" 2>/dev/null)" \ + && [[ -n "$_fetched" ]]; then + WIRE_KEY="$_fetched"; WIRE_KEY_SRC="the master's VAULT (cred:$SERVICE — #216, the agent's authorized key)" + fi + fi + if [[ -z "$WIRE_KEY" && -n "$LLM_API_KEY" ]]; then + WIRE_KEY="$LLM_API_KEY" + WIRE_KEY_SRC="operator env \$OPENROUTER_API_KEY (DEV fallback — vault cred:$SERVICE unavailable; #216 wants the vault: grant the cred scope + vault the key, see harness/cred-wire-demo.sh)" + fi + if [[ -z "$WIRE_KEY" ]]; then + skip "4.0 hermes llm" "no LLM key — neither a vaulted cred:$SERVICE (the #216 path; proven by harness/cred-wire-demo.sh) nor \$OPENROUTER_API_KEY (dev fallback). Skipping the surprise." return fi # 4.0a wiring precheck — the surprise is only memory-aware if the wire hooks @@ -1069,12 +1106,12 @@ phase4_surprise() { # be single-line: the sandbox /v1/shell/exec rejects multi-line payloads with # a silent ErrorObservation. Verified (not masked with || true). local env_path='$HOME/.hermes/.env' - sbx_exec "ENV=$env_path; grep -v '^OPENROUTER_API_KEY=' \"\$ENV\" > \"\$ENV.tmp\" 2>/dev/null; printf 'OPENROUTER_API_KEY=%s\n' $(printf '%q' "$LLM_API_KEY") >> \"\$ENV.tmp\"; mv \"\$ENV.tmp\" \"\$ENV\"" >/dev/null + sbx_exec "ENV=$env_path; grep -v '^OPENROUTER_API_KEY=' \"\$ENV\" > \"\$ENV.tmp\" 2>/dev/null; printf 'OPENROUTER_API_KEY=%s\n' $(printf '%q' "$WIRE_KEY") >> \"\$ENV.tmp\"; mv \"\$ENV.tmp\" \"\$ENV\"" >/dev/null if [[ "$(sbx_rc "grep -q '^OPENROUTER_API_KEY=' $env_path")" != "0" ]]; then fail "4.0 hermes llm" "could not write OPENROUTER_API_KEY to ~/.hermes/.env"; return fi sbx_exec "export PATH=\$HOME/.local/bin:\$PATH; hermes config set model.provider openrouter >/dev/null 2>&1; hermes config set model.base_url $(printf '%q' "$LLM_BASE_URL") >/dev/null 2>&1; hermes config set model.default $(printf '%q' "$LLM_MODEL") >/dev/null 2>&1" >/dev/null - ok "4.0 hermes llm" "provider=openrouter, model=$LLM_MODEL, key in ~/.hermes/.env" + ok "4.0 hermes llm" "provider=openrouter, model=$LLM_MODEL, key from $WIRE_KEY_SRC" # 4.1 model smoke (non-fatal) — surface throttling/credential errors BEFORE # the manual surprise, so the operator isn't debugging during the chat. From 346ef5eb13c9bf07df0ddf1ea54c6373af030fa6 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 03:16:35 +0800 Subject: [PATCH 04/85] =?UTF-8?q?feat:=20#216=20`agentkeys=20cred=20store`?= =?UTF-8?q?=20=E2=80=94=20symmetric=20store=20half=20+=20#204=20daemon=20f?= =?UTF-8?q?ix=20(verified=20live)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the CLI cred surface with the store half of `cred fetch`, and folds the daemon's hand-rolled cred-store body into the crate (closing a #204 drift gap): - agentkeys-backend-client: `CredStoreBody`/`CredStoreResp`/`CredStoreInput`/ `CredStoreResult` (mirror the CredFetch types) + `BackendClient::cred_store` (cap-mint CredStore → per-actor STS under the VAULT role → cred worker `/v1/cred/store` → encrypt + S3 PUT). Exported from the crate. - agentkeys-daemon: `store_master_credential_inner` now builds the worker body from the crate-owned `CredStoreBody` instead of an inline `serde_json::json!({...})` (#204 — "broker/worker request shapes have ONE owner"; a drifted field is now a compile error, matching the memory-put path). - agentkeys-cli: `agentkeys cred store --secret|--secret-env` (master-self by default). `--secret-env NAME` keeps the plaintext off argv / out of the shell history + process list. Prints the worker S3 key. VERIFIED LIVE (CLI-only store→fetch round-trip, master-self): stored `cred-store-probe` → bots/941…/credentials/cred-store-probe.enc ✅ CLI store→fetch ROUND-TRIP PASS — agentkeys cred store works end-to-end Scope note: this is the master-self vault primitive. The master provisioning a key INTO the agent's S3 prefix (so the agent fetches with actor=agent) needs dual bearers (operator session for cap-mint + agent session for the STS PrincipalTag) and is #214's authorization-side job — deliberately out of #216 scope. clippy -D warnings clean; cargo check green. --- crates/agentkeys-backend-client/src/client.rs | 40 ++++++++++- crates/agentkeys-backend-client/src/lib.rs | 5 +- .../agentkeys-backend-client/src/protocol.rs | 30 ++++++++ crates/agentkeys-cli/src/cred_admin.rs | 55 ++++++++++++++- crates/agentkeys-cli/src/main.rs | 70 +++++++++++++++++++ crates/agentkeys-daemon/src/ui_bridge.rs | 8 ++- 6 files changed, 200 insertions(+), 8 deletions(-) diff --git a/crates/agentkeys-backend-client/src/client.rs b/crates/agentkeys-backend-client/src/client.rs index 318ed0bf..a001801e 100644 --- a/crates/agentkeys-backend-client/src/client.rs +++ b/crates/agentkeys-backend-client/src/client.rs @@ -13,8 +13,9 @@ use reqwest::Client; use crate::protocol::{ AuditAppendInput, AuditAppendResult, AuditAppendV2, AuditAppendV2Resp, BrokerCapRequest, CapMintOp, CapMintRequest, CapToken, CredFetchBody, CredFetchInput, CredFetchResp, - CredFetchResult, MemoryGetBody, MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody, - MemoryPutInput, MemoryPutResp, MemoryPutResult, RevokeResult, ENVELOPE_VERSION, + CredFetchResult, CredStoreBody, CredStoreInput, CredStoreResp, CredStoreResult, MemoryGetBody, + MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput, MemoryPutResp, + MemoryPutResult, RevokeResult, ENVELOPE_VERSION, }; #[derive(thiserror::Error, Debug)] @@ -314,6 +315,41 @@ impl BackendClient { }) } + /// `POST /v1/cred/store` — vault a credential (#216). The signed cap carries + /// the `service` + data-class; the per-actor STS creds (vault role) scope the + /// S3 PUT to `bots//credentials/`. The plaintext is base64 in the body + /// (the worker encrypts with the K3 KEK). + pub async fn cred_store(&self, input: CredStoreInput) -> Result { + let url = format!("{}/v1/cred/store", self.cred()?); + let mut req = self.client.post(&url).json(&CredStoreBody { + cap: input.cap, + plaintext_b64: input.plaintext_b64, + }); + if let Some(headers) = self.sts_headers(self.vault_role_arn.as_ref()).await? { + for (k, v) in headers { + req = req.header(k, v); + } + } + let resp = req + .send() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(BackendError::Http { status, body }); + } + let parsed: CredStoreResp = resp + .json() + .await + .map_err(|e| BackendError::Parse(e.to_string()))?; + Ok(CredStoreResult { + ok: parsed.ok, + s3_key: parsed.s3_key, + envelope_size: parsed.envelope_size, + }) + } + /// `POST /v1/audit/append/v2` — append a signed audit envelope. `ts_unix` /// is stamped here; `intent_commitment` is always `None` on this side /// (the broker computes it). diff --git a/crates/agentkeys-backend-client/src/lib.rs b/crates/agentkeys-backend-client/src/lib.rs index f9e5ab01..2929719d 100644 --- a/crates/agentkeys-backend-client/src/lib.rs +++ b/crates/agentkeys-backend-client/src/lib.rs @@ -20,6 +20,7 @@ pub use protocol::{ normalize_omni_0x, service_memory, AuditAppendInput, AuditAppendResult, AuditAppendV2, AuditAppendV2Resp, BrokerCapRequest, CapMintOp, CapMintRequest, CapToken, ConfigGetBody, ConfigGetResp, ConfigPutBody, CredFetchBody, CredFetchInput, CredFetchResp, CredFetchResult, - MemoryGetBody, MemoryGetInput, MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput, - MemoryPutResp, MemoryPutResult, RevokeResult, ENVELOPE_VERSION, + CredStoreBody, CredStoreInput, CredStoreResp, CredStoreResult, MemoryGetBody, MemoryGetInput, + MemoryGetResp, MemoryGetResult, MemoryPutBody, MemoryPutInput, MemoryPutResp, MemoryPutResult, + RevokeResult, ENVELOPE_VERSION, }; diff --git a/crates/agentkeys-backend-client/src/protocol.rs b/crates/agentkeys-backend-client/src/protocol.rs index 19006237..f68df4bb 100644 --- a/crates/agentkeys-backend-client/src/protocol.rs +++ b/crates/agentkeys-backend-client/src/protocol.rs @@ -191,6 +191,23 @@ pub struct CredFetchResp { pub plaintext_b64: String, } +/// Cred-worker `/v1/cred/store` request body. Mirrors +/// `agentkeys_worker_creds::handlers::StoreRequest` — the signed cap (the +/// credential `service` rides INSIDE the cap payload) plus the base64 plaintext. +/// The worker encrypts (K3 KEK) + S3-PUTs `bots//credentials/.enc`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredStoreBody { + pub cap: CapToken, + pub plaintext_b64: String, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct CredStoreResp { + pub ok: bool, + pub s3_key: String, + pub envelope_size: usize, +} + // ── audit worker (`/v1/audit/append/v2`) ──────────────────────────────────── /// Audit envelope version, pinned to `agentkeys_core::audit::ENVELOPE_VERSION`. @@ -260,6 +277,19 @@ pub struct CredFetchResult { pub plaintext_b64: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredStoreInput { + pub cap: CapToken, + pub plaintext_b64: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CredStoreResult { + pub ok: bool, + pub s3_key: String, + pub envelope_size: usize, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AuditAppendInput { pub operator_omni: String, diff --git a/crates/agentkeys-cli/src/cred_admin.rs b/crates/agentkeys-cli/src/cred_admin.rs index d0b37164..540da6f4 100644 --- a/crates/agentkeys-cli/src/cred_admin.rs +++ b/crates/agentkeys-cli/src/cred_admin.rs @@ -13,7 +13,7 @@ use anyhow::{Context, Result}; use base64::{engine::general_purpose::STANDARD, Engine as _}; use agentkeys_backend_client::{ - normalize_omni_0x, BackendClient, CapMintOp, CapMintRequest, CredFetchInput, + normalize_omni_0x, BackendClient, CapMintOp, CapMintRequest, CredFetchInput, CredStoreInput, }; /// Fetch + decrypt the credential `service` the actor is authorized for, returning @@ -65,3 +65,56 @@ pub async fn cred_fetch( .context("decode cred plaintext_b64")?; String::from_utf8(bytes).context("cred plaintext is not valid UTF-8") } + +/// Vault the credential `service` = `secret` (the symmetric store half of +/// [`cred_fetch`]). `operator_omni` == `actor_omni` for a master-self store (the +/// master vaulting into its OWN vault — the common case, e.g. seeding the agent's +/// LLM key). Returns the worker's S3 key. Routes through the shared +/// `agentkeys-backend-client` (#204): cap-mint (`CredStore`) → per-actor STS under +/// the VAULT role → cred worker `/v1/cred/store` → encrypt + S3 PUT. +#[allow(clippy::too_many_arguments)] +pub async fn cred_store( + service: &str, + secret: &str, + operator_omni: &str, + actor_omni: &str, + device_key_hash: &str, + session_bearer: &str, + broker_url: &str, + cred_url: &str, + vault_role_arn: &str, + region: &str, +) -> Result { + let client = BackendClient::new( + Some(broker_url.to_string()), + None, // memory_url + None, // audit_url + Some(cred_url.to_string()), + Some(session_bearer.to_string()), // session bearer → per-actor STS + None, // memory_role_arn + Some(vault_role_arn.to_string()), + region.to_string(), + ); + let cap = client + .cap_mint( + CapMintOp::CredStore, + CapMintRequest { + operator_omni: normalize_omni_0x(operator_omni), + actor_omni: normalize_omni_0x(actor_omni), + service: service.to_string(), + device_key_hash: device_key_hash.to_string(), + ttl_seconds: 300, + }, + session_bearer, + ) + .await + .with_context(|| format!("cap-mint cred-store for service `{service}`"))?; + let result = client + .cred_store(CredStoreInput { + cap, + plaintext_b64: STANDARD.encode(secret.as_bytes()), + }) + .await + .with_context(|| format!("cred worker store for service `{service}`"))?; + Ok(result.s3_key) +} diff --git a/crates/agentkeys-cli/src/main.rs b/crates/agentkeys-cli/src/main.rs index cf340c38..ecef8689 100644 --- a/crates/agentkeys-cli/src/main.rs +++ b/crates/agentkeys-cli/src/main.rs @@ -425,6 +425,36 @@ enum CredAction { #[arg(long, env = "REGION", default_value = "us-east-1")] region: String, }, + /// Vault a credential (#216, the store half of `fetch`). Master-self by + /// default (operator == actor); seeds the agent's authorized key (e.g. the + /// LLM key the agent later cred-fetches). Prints the worker S3 key. + Store { + /// The credential service id (e.g. `openrouter`). + service: String, + /// The secret to vault. Prefer `--secret-env NAME` to keep it off argv. + #[arg(long, conflicts_with = "secret_env")] + secret: Option, + /// Read the secret from this env var instead of `--secret` (keeps the + /// plaintext out of the process list / shell history). + #[arg(long)] + secret_env: Option, + #[arg(long, env = "AGENTKEYS_OPERATOR_OMNI")] + operator_omni: String, + #[arg(long, env = "AGENTKEYS_ACTOR_OMNI")] + actor_omni: String, + #[arg(long, env = "AGENTKEYS_DEVICE_KEY_HASH")] + device_key_hash: String, + #[arg(long, env = "AGENTKEYS_SESSION_BEARER")] + session_bearer: String, + #[arg(long, env = "AGENTKEYS_BROKER_URL")] + broker_url: String, + #[arg(long, env = "AGENTKEYS_WORKER_CRED_URL")] + cred_url: String, + #[arg(long, env = "VAULT_ROLE_ARN")] + vault_role_arn: String, + #[arg(long, env = "REGION", default_value = "us-east-1")] + region: String, + }, } #[derive(Subcommand)] @@ -1447,6 +1477,46 @@ async fn main() { ) .await } + CredAction::Store { + service, + secret, + secret_env, + operator_omni, + actor_omni, + device_key_hash, + session_bearer, + broker_url, + cred_url, + vault_role_arn, + region, + } => { + let resolved: anyhow::Result = match (secret, secret_env) { + (Some(s), _) => Ok(s.clone()), + (None, Some(env_name)) => std::env::var(env_name).map_err(|_| { + anyhow::anyhow!("--secret-env {env_name} is not set in the environment") + }), + (None, None) => Err(anyhow::anyhow!( + "provide the secret via --secret or --secret-env " + )), + }; + match resolved { + Ok(secret_value) => agentkeys_cli::cred_admin::cred_store( + service, + &secret_value, + operator_omni, + actor_omni, + device_key_hash, + session_bearer, + broker_url, + cred_url, + vault_role_arn, + region, + ) + .await + .map(|s3_key| format!("stored `{service}` → {s3_key}")), + Err(e) => Err(e), + } + } }, }; diff --git a/crates/agentkeys-daemon/src/ui_bridge.rs b/crates/agentkeys-daemon/src/ui_bridge.rs index 172eb7cb..21f20bbd 100644 --- a/crates/agentkeys-daemon/src/ui_bridge.rs +++ b/crates/agentkeys-daemon/src/ui_bridge.rs @@ -3579,9 +3579,11 @@ async fn store_master_credential_inner( .header("x-aws-access-key-id", creds.access_key_id) .header("x-aws-secret-access-key", creds.secret_access_key) .header("x-aws-session-token", creds.session_token) - .json( - &serde_json::json!({ "cap": cap, "plaintext_b64": STANDARD.encode(secret.as_bytes()) }), - ) + // Crate-owned body shape (#204) — a drifted field is a compile error. + .json(&agentkeys_backend_client::CredStoreBody { + cap, + plaintext_b64: STANDARD.encode(secret.as_bytes()), + }) .send() .await .map_err(|e| { From 7978716fd6dcfbf45b570c9a437350890919c069 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 13:42:36 +0800 Subject: [PATCH 05/85] docs: #216 make operator-runbook-wire.md the single source of truth (web app + CLI, fresh start) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the wire runbook from a CLI/sandbox + memory-only "run the demo" doc into the single fresh-start guide for testing the WHOLE wire — both the #216 vault-fetched LLM key and the permissioned memory — two ways: - New top: the two guarantees, a two-paths table (web app vs CLI, same agent side), the fastest test (`harness/cred-wire-demo.sh`), and a fresh-start checklist (3 setup scripts + sandbox + OpenRouter key + master identity). - Path A — Web app: `bash dev.sh` → onboard → vault the key (credentials page) → pair+authorize (pairing page, Touch ID). Honest "wired vs pending" note: the web vault + #214 pairing are real/on-chain today; the agent-identity vault-fetch needs #214's dual-bearer master-provisioning (not wired yet), so the master-self cred-wire-demo is the end-to-end proof. - Path B — CLI: the existing phase1-wire-demo walkthrough, reframed. - LLM-key gate now documents Phase 4.0b vault-first/env-fallback; "Verifying it worked" splits into the two deterministic checks; +3 web/cred troubleshooting rows; Appendix B gains the `cred store`/`cred fetch` primitives; cross-refs add the new demos + #216/#214 + dev.sh. keep-docs-in-sync: folds back the cred-wire-demo + cred-store + Phase 4.0b changes from this PR into the operator runbook. --- docs/operator-runbook-wire.md | 180 +++++++++++++++++++++++++++++----- 1 file changed, 155 insertions(+), 25 deletions(-) diff --git a/docs/operator-runbook-wire.md b/docs/operator-runbook-wire.md index 0c49a87c..73b5db0a 100644 --- a/docs/operator-runbook-wire.md +++ b/docs/operator-runbook-wire.md @@ -1,32 +1,140 @@ -# Operator runbook — run the `agentkeys wire` demo +# Test the agent wire — web app + CLI, from fresh -**This is the single doc to follow to run the demo.** It drives the harness -`harness/phase1-wire-demo.sh`, which automates the whole flow and stops only at -the essential manual gates. Goal: see the Agent IAM "surprise" — a device that -reads only its permitted memory, is deterministically denied an over-cap action -(no LLM in the decision), and complies on revocation. +**This is the single source of truth for testing the AgentKeys "wire".** The wire +is what makes an agent run on **exactly what its master authorized** — its **LLM +key fetched from the master's vault** (never an ambient env var, #216) and the +**memory namespaces it was granted** — behind IAM-guarantee hooks the LLM cannot +bypass. This doc takes you from a **fresh machine** to a green end-to-end test +**two ways**: the **web app** (the master authorizes through the parent-control UI) +and the **CLI** (the master authorizes from the shell). The **agent side is +identical** either way; the paths differ only in how the master authorizes. > **Architecture (1 paragraph)**: AgentKeys is the **Authority Host**; the Task -> Host (Hermes) does the work. `agentkeys wire hermes` writes IAM-guarantee -> **hooks** into Hermes's config so the LLM cannot bypass `permission.check` / -> `audit.append` / memory injection. Background: [`docs/agent-iam-strategy.md`](agent-iam-strategy.md) -> §3.6–3.7, [`docs/arch.md`](arch.md) §22d, [`docs/wiki/agent-iam-guarantee-glossary.md`](wiki/agent-iam-guarantee-glossary.md). +> Host (Hermes) does the work. `agentkeys wire hermes` plants the **vault-fetched +> LLM key** into Hermes' model config (#216) AND writes IAM-guarantee **hooks** so +> the LLM cannot bypass `permission.check` / `audit.append` / memory injection. +> Background: [`docs/agent-iam-strategy.md`](agent-iam-strategy.md) §3.6–3.7, +> [`docs/arch.md`](arch.md) §22d / §10.2 (agent pairing), +> [`docs/wiki/agent-iam-guarantee-glossary.md`](wiki/agent-iam-guarantee-glossary.md). > Full action table + automation decisions: [`docs/plan/phase1-wire-harness-test-plan.md`](plan/phase1-wire-harness-test-plan.md). -## TL;DR — one command +## What the wire proves (two guarantees) -> **Real memory only (#207):** the in-memory `--light` mode was **removed** — -> there is no fake/self-contained path anymore. The sandbox MCP always runs -> `--backend http` against the real broker + workers + Heima mainnet. `--real` -> is the only mode (and the default). +1. **Authorized LLM key (#216)** — the agent's Hermes runs on the **key the master + vaulted + authorized**, fetched via the agent's `cred:` scope (cap-mint + → per-actor STS → cred worker → decrypt). **No `OPENROUTER_API_KEY` in the agent's + env.** +2. **Permissioned memory** — the agent reads **only** the granted namespaces; an + over-cap action is **deterministically denied** (no LLM in the decision) and + complies on revocation. + +## The two paths (same agent side, different master side) + +The master authorizes the agent two ways; the agent side (fetch → wire → run, in the +sandbox) is identical: + +| | **Path A — Web app** | **Path B — CLI** | +|---|---|---| +| Vault the LLM key | parent-control UI → *credentials* | `agentkeys cred store` (or the daemon) | +| Pair + authorize the agent | parent-control UI → *pairing* (claim code → Touch ID) | `agentkeys agent claim` (phase1-wire Phase P) | +| Drives the agent side | the sandbox (harness) | the sandbox (harness) | + +## Fastest test — one headless command (no UI, no Touch ID) + +To confirm the #216 wire works end-to-end against the live stack, run the headless +full e2e. It vaults a key, fetches it back **as the agent**, plants it into the +sandbox Hermes, and asserts Hermes answers on the **vault** key (a real LLM call): + +```bash +bash harness/cred-wire-demo.sh +# step 4 ok agent fetched the vaulted key from the vault — no env read +# step 6 ok 6.1 vault-sourced — the key Hermes uses == the master-vaulted key, NOT an env var +# step 6 ok 6.2 llm smoke — Hermes answered using the VAULT-FETCHED key: "OK" +``` + +It is **master-self** (operator == actor), so it needs no Touch ID and proves the +vault → fetch → plant → run mechanism. (Its store→fetch primitive alone: +`bash harness/cred-fetch-demo.sh`.) Paths A and B add the real +master-authorizes-a-distinct-agent UX on top. + +> **Real data only (#207):** the in-memory `--light` mode was **removed** — there is +> no fake/self-contained path. The sandbox MCP always runs `--backend http` against +> the real broker + workers + Heima mainnet. `--real` is the only mode (and default). + +## Fresh start — do this first (both paths) + +1. **Bring up the live infra** — the three idempotent setup scripts (detailed under + **Setup entry points**, below): `setup-cloud.sh` (laptop) → `setup-broker-host.sh` + (broker host) → `setup-heima.sh` (laptop). These give you the broker, the **cred + worker**, the other workers, and the chain contracts + your registered master. +2. **The agent host** — Docker + the aiosandbox running (the agent + Hermes live + here). See **Prerequisites**, below. +3. **The LLM key to vault** — `export OPENROUTER_API_KEY=…` (the master's key; the + point is the agent reads it back from the **vault**, not the env). +4. **Your master identity** — `setup-heima.sh` registered it; `OPERATOR_KEY_FILE` + (default `~/.agentkeys/heima-deployer.key`) is the master key the daemon/CLI sign + sessions with. + +Then pick **Path A** (web app) or **Path B** (CLI) below — or just run the **fastest +test** above. + +## Path A — Web app (the master authorizes through the UI) + +The parent-control web app is the master's console: it **vaults the key** and **pairs ++ authorizes the agent** through the real daemon → broker/worker chain (no mock data). + +1. **Start the stack** (builds the daemon + MCP if needed; sources + `scripts/operator-workstation.env` so the daemon inherits the cred/vault/memory + env — `VAULT_ROLE_ARN`, `AGENTKEYS_WORKER_CRED_URL`, …): + ```bash + bash dev.sh + # [daemon] http://localhost:3114 [mcp] http://localhost:18088 [ui] http://localhost:3113 + ``` +2. **Onboard the master** — open and complete onboarding + (email magic-link verify → on-chain master register). You land on the dashboard + with a live master session (the daemon starts **unseeded** — onboarding is what + authorizes its cap-mint). +3. **Vault the LLM key** — go to **credentials**, enter `openrouter` + your OpenRouter + API key, **⊕ store**. This POSTs `/v1/master/credentials/store` → cap-mint + (cred-store) → per-actor STS → cred worker → encrypt + S3 + `bots//credentials/openrouter.enc`. The table shows + `openrouter · ai-services · cred:openrouter`. +4. **Pair + authorize the agent** — the agent (in the sandbox) shows a one-time + pairing code (`agentkeys-daemon --request-pairing`; this is the §10.2 **agent + side**). Go to **pairing** → paste the code + a label → **⊕ claim** → review the + device + requested scope (incl. `cred:openrouter` + `memory:`) → **accept + pairing · Touch ID**. That one approval submits `registerAgentDevice` + the scope + grants on-chain (#214 / arch §10.2). +5. **Run the agent side** — the sandbox agent fetches its authorized key + memory and + wires Hermes; verify per **Verifying it worked**, below. (The fully-automated + agent side is the harness — Path B / the fastest test.) + +> **What's wired vs. pending (be honest with yourself when testing):** the web +> *credentials* vault (step 3) and the *pairing* authorize (step 4) are **real + +> on-chain today**. The final hop — the agent fetching **this** vaulted key with +> **its own** (actor = agent) identity — needs the master to provision the key into +> the **agent's** vault prefix (`bots//…`), which needs dual bearers +> (operator session for the cap-mint + agent session for the STS tag). That +> master-provisioning is **#214's authorization side and is not wired yet**. Until it +> lands, the **master-self** fastest test (`cred-wire-demo.sh`) is the end-to-end +> proof of fetch → wire → run, and Path B's Phase 4.0b fetches **vault-first with an +> env fallback**. (Tracked in [#216](https://github.com/litentry/agentKeys/issues/216).) + +## Path B — CLI (the `phase1-wire-demo.sh` walkthrough) + +The CLI path automates the whole master + agent flow in one harness — the master side +is the shell (`agentkeys cred store` for the key, `agentkeys agent claim` for the +pairing), the agent side is the sandbox, and it stops only at the essential manual +gates. It prints a loud `MODE:` banner, then `ok proceeding` / `skip ` / +`fail ` per step; it is idempotent — re-running is safe. (Passing the old +`--light` flag now errors with a pointer to `--real`.) ```bash # The live product on your heima account + real broker/workers + Heima mainnet. # Runs a FRESH §10.2 pairing EACH run: the agent generates its own key IN THE # SANDBOX (never on the master), the master binds it on-chain, and --webauthn -# "approves" the memory scope via Touch ID. Then it seeds + recalls the Chengdu -# memory. Each run DEPAIRS the prior device (revoke) + re-pairs a fresh K10 -# (register), so expect ONE Touch ID + ~2 on-chain txs per run. +# "approves" the memory scope via Touch ID. Each run DEPAIRS the prior device +# (revoke) + re-pairs a fresh K10 (register) — ONE Touch ID + ~2 on-chain txs/run. bash harness/phase1-wire-demo.sh --real --webauthn # VERIFY — deterministic, no LLM. Run IN THE SANDBOX after setup (the harness @@ -37,11 +145,7 @@ docker exec -it bash -lc "hermes hooks test pre_llm_call" # → stdout: {} ❌ (MCP down / scope not granted / session bad) ``` -The harness prints a loud `MODE:` banner, then `ok proceeding` / `skip ` / -`fail ` per step; it is idempotent — re-running is safe. (Passing the old -`--light` flag now errors with a pointer to `--real`.) - -## How to run — the `--real --webauthn` walkthrough +### Path B walkthrough — what happens, step by step One command runs the whole "install an app → approve its permissions → use it" story. Each run does a **genuine fresh pairing** — it **depairs** the prior device @@ -154,7 +258,7 @@ All three are **idempotent + unattended by default** — re-running converges an ## The manual gates (the "test through" essence) -- **LLM key** — auto from `OPENROUTER_API_KEY` (or `LLM_API_KEY`); only prompts if absent. Phase 4.0 writes it to the sandbox `~/.hermes/.env` and sets `provider: openrouter` + `model.default` (default `deepseek/deepseek-v4-flash`; override `LLM_MODEL`). A non-fatal `4.1 model smoke` confirms the model is live before the surprise. +- **LLM key** — **#216: Phase 4.0b fetches the agent's key from the master's VAULT first** (`agentkeys cred fetch` over the agent's `cred:` scope); `OPENROUTER_API_KEY`/`LLM_API_KEY` is a **dev-only fallback**, used only when the vault fetch is unavailable. Whichever source wins, Phase 4.0 writes it to the sandbox `~/.hermes/.env` and sets `provider: openrouter` + `model.default` (default `deepseek/deepseek-v4-flash`; override `LLM_MODEL`) — the `4.0 ok` line prints **which source** was used. A non-fatal `4.1 model smoke` confirms the model is live before the surprise. - **Install (pair)** — `--real` only: **Phase P** — `P.depair` revokes any prior device + wipes the sandbox K10 so the re-pair is genuine, the agent daemon (`agentkeys-daemon --request-pairing`) generates a **fresh** device key **in the sandbox** + shows a pairing code (`P.0`), the master **claims** it (`P.1`), the agent daemon (`agentkeys-daemon --retrieve-pairing`) retrieves `J1_agent` (`P.1b`), the master does a real `registerAgentDevice` (`P.2`), and — with `--webauthn` — the master **approves** the agent's memory scope via Touch ID (`P.3`). Every run depairs + re-pairs the same agent with a fresh device key (~2 on-chain txs). `--reuse-agent` skips all of Phase P. - **Real Touch ID** — in real mode with `--webauthn`: **Phase P (P.3)** grants the freshly-paired agent's memory scope via `heima-scope-set.sh --webauthn`. The banner prints `webauthn=` so you know upfront whether a Touch ID ceremony will run. It's a hardware prompt — `--yes` does NOT bypass it (it only auto-confirms the software "proceed?" gates). Without `--webauthn`, `P.3` is skipped (the agent won't be able to read memory) — re-run with `--real --webauthn`. - **Seed the real memory worker** (`--real` only) — after pairing, step **1.5** (re)writes the Chengdu fixture into the agent's memory namespace (keyed by the **stable** omni; 1.5 overwrites each run). Override `SEED_MEMORY_CONTENT` / `SEED_SCOPE_SERVICES` (the latter is the service set Phase P grants — it **sets** the full list). @@ -215,6 +319,16 @@ calls `EntryPoint.handleOps` directly. Full design + cutover status: ## Verifying it worked — deterministically (no LLM inference) +There are **two** deterministic checks, one per guarantee: + +- **Authorized key (#216)** — `harness/cred-wire-demo.sh` **step 6** asserts the key + in the sandbox `~/.hermes/.env` is **byte-identical (sha) to the master-vaulted + key** and arrived via the vault fetch, not an ambient env var. (Path B's `4.0 ok` + line also prints the key source — `the master's VAULT …` vs `operator env … (DEV + fallback)`.) +- **Permissioned memory** — `hermes hooks test pre_llm_call` (below) asserts the + granted memory reached the LLM request. + **Do NOT judge success by the chat reply.** An LLM may phrase a memory-aware answer many ways, treat a past-dated memory as "not this weekend", or even *disown* the injected context as a hallucination — the prose is not a reliable @@ -286,6 +400,9 @@ Re-running `agentkeys wire hermes` is always safe — unchanged scripts/config s | Symptom | Cause | Fix | |---|---|---| +| **Path A** UI shows "disconnected" / empty states | the web app can't reach the daemon | confirm `bash dev.sh` is up (daemon on `:3114`); the UI needs `NEXT_PUBLIC_AGENTKEYS_BACKEND=daemon` + `NEXT_PUBLIC_AGENTKEYS_DAEMON_URL=http://localhost:3114` (dev.sh sets both). Check the daemon `/healthz`. | +| **Path A** vault store → error / `no cred worker configured` | the daemon didn't inherit the cred env | the daemon reads `AGENTKEYS_WORKER_CRED_URL` + `VAULT_ROLE_ARN` from its env; `dev.sh` sources `scripts/operator-workstation.env`, so set them there + restart `dev.sh`. | +| `agentkeys cred fetch` → `ServiceNotInScope` / empty | the actor isn't scoped for `cred:`, or nothing's vaulted under that actor's prefix | **master-self** (operator == actor) skips the scope check — use it for the fastest test. For a distinct agent the cred scope must be granted (pairing P.3) AND the key vaulted at `bots//credentials/.enc` (the #214 master-provisioning — see Path A's "wired vs pending" note). | | cap-mint → 401 `ExpiredSignature` | the operator session JWT expired | the harness now auto-mints a fresh one at `0.7` via `wallet_sig` (`OPERATOR_KEY_FILE`). If it didn't: ensure `cast` is on PATH and `OPERATOR_KEY_FILE` exists | | cap-mint → 401/`OperatorMismatch` (`session_omni != operator_omni`) | the session is for a *different* operator (e.g. the legacy `alice` email session, omni `4231cd8f…` ≠ agent operator `941cb1c3…`) | `0.7` now detects the omni mismatch and re-mints from `OPERATOR_KEY_FILE`. If `0.7` fails with "wrong operator", point `OPERATOR_KEY_FILE` at the master key whose broker omni == `operator_omni` | | memory put/get → HTTP **502** `{"reason":"s3_put"}` / `{"reason":"s3_get"}` | the MCP `http` backend didn't forward per-actor STS creds, so the worker fell back to its EC2 instance profile (SES-only, **no S3**) → AccessDenied on every op. cap-mint + chain-verify themselves SUCCEED (the agent IS authorized); the gap was the credential **relay**. | **Fixed (issue #90):** the backend now mints agent-tagged STS creds (`0.8` agent session → broker `/v1/mint-oidc-jwt` → `AssumeRoleWithWebIdentity(memory-role)`, tagged `agentkeys_actor_omni`) and forwards them as `X-Aws-*` headers, so AWS scopes S3 to `bots//memory/`. If it still 502s: confirm `P.1b retrieve` shows "agent retrieved J1_agent in-sandbox" (or `0.8 agent session` under `--reuse-agent`) and `P.3 grant` granted the scope; the relay needs the in-sandbox agent session + `MEMORY_ROLE_ARN`/`VAULT_ROLE_ARN`/`REGION` from `operator-workstation.env`. Optional strict enforcement: set `AGENTKEYS_WORKER_REQUIRE_STS=1` in the worker env (rejects credless requests with 401 instead of falling back). | @@ -340,6 +457,16 @@ Each `~/.hermes/agent-hooks/*.sh` bakes the identity env (actor, operator, MCP U The harness does these for you; run them manually only to understand the flow. ```bash +# 0. #216 — vault the LLM key (master-self), then fetch it back as the agent. +# (master-self: operator == actor; for a distinct agent set --actor-omni to the agent.) +agentkeys cred store openrouter --secret-env OPENROUTER_API_KEY \ + --operator-omni 0x --actor-omni 0x --device-key-hash 0x \ + --session-bearer "$J1" --broker-url … --cred-url … --vault-role-arn "$VAULT_ROLE_ARN" --region us-east-1 +agentkeys cred fetch openrouter \ + --operator-omni 0x --actor-omni 0x --device-key-hash 0x \ + --session-bearer "$J1" --broker-url … --cred-url … --vault-role-arn "$VAULT_ROLE_ARN" --region us-east-1 +# → prints the exact secret (decrypt-on-read) — this is what Phase 4.0b plants into ~/.hermes/.env. + # 1. Sandbox (see Prerequisites). # 2. MCP server (real http backend — the only backend; in-memory was removed #207): ./target/release/agentkeys-mcp-server --backend http --transport http --listen 127.0.0.1:18088 \ @@ -366,8 +493,11 @@ echo '{"tool_name":"x"}' | agentkeys hook audit ## Cross-references -- [`harness/phase1-wire-demo.sh`](../harness/phase1-wire-demo.sh) — the harness this runbook drives +- [`harness/cred-wire-demo.sh`](../harness/cred-wire-demo.sh) — the **fastest test** (#216 full vault→fetch→wire→run e2e, headless) · [`harness/cred-fetch-demo.sh`](../harness/cred-fetch-demo.sh) — the cred store→fetch round-trip +- [`harness/phase1-wire-demo.sh`](../harness/phase1-wire-demo.sh) — Path B (the CLI walkthrough this runbook drives) · [`dev.sh`](../dev.sh) — Path A (the web-app dev stack: daemon + MCP + UI) +- `agentkeys cred store --secret-env NAME` / `agentkeys cred fetch ` — the master-self vault + agent-fetch CLI primitives (#216) - [`docs/plan/phase1-wire-harness-test-plan.md`](plan/phase1-wire-harness-test-plan.md) — the action table + automation decisions +- [Issue #216](https://github.com/litentry/agentKeys/issues/216) — agent-side vaulted-key wire · [Issue #214](https://github.com/litentry/agentKeys/issues/214) — master-side web pairing - [`docs/agent-iam-strategy.md`](agent-iam-strategy.md) §3.6/§3.7/§4.3 · [`docs/arch.md`](arch.md) §22d · [`docs/wiki/agent-iam-guarantee-glossary.md`](wiki/agent-iam-guarantee-glossary.md) - [Issue #133](https://github.com/litentry/agentKeys/issues/133) — multi-runtime hook reference configs (Phase 1.b) - [Issue #152](https://github.com/litentry/agentKeys/issues/152) — **scope:** this runbook covers the **Local-LLM / Task-agent** path only (stdio MCP server built + run *in the sandbox*). The **Hosted-LLM** path (xiaozhi / vendor-cloud — a broker-hosted `mcp-endpoint` the remote LLM connects *into*, per arch.md §22c.2 / §22d.3) is deferred to #152. From a8bf1bd6cb29675787a0862b688d92a410f371d3 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 15:16:02 +0800 Subject: [PATCH 06/85] =?UTF-8?q?docs:=20#216=20fix=20Path=20A=20=E2=80=94?= =?UTF-8?q?=20the=20web=20app=20doesn't=20provision=20the=20agent=20device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caught in review: Path A had the agent run in the sandbox (agentkeys-daemon --request-pairing → cred fetch → wire hermes) but never said how the compiled agentkeys / agentkeys-daemon / agentkeys-mcp-server binaries get INTO the sandbox. They can't run there unless cross-built for the sandbox's Linux arch and uploaded (the sandbox is aarch64/x86 Linux, not the operator's Mac) — which is what Path B / phase1-wire-demo.sh Phase 1 does (target/sandbox-linux cross-build → sbx_put). Rewrote Path A to be honest: - The web app is ONLY the master's console; it does not provision the agent device. - A. Vault the LLM key — fully standalone (no sandbox). - B. Pair — needs the agent binaries in the sandbox first; and phase1-wire's Phase 1 bundles the cross-build/upload WITH the CLI pairing (Phase P lives inside Phase 1), so there's no clean "binaries only" command and no one-command web-pairing flow yet (drive the web claim by hand: upload binaries, open a request, claim in the UI). - C. End-to-end is the headless cred-wire-demo.sh / Path B. Also corrected my own first attempt, which suggested `--skip-2..5` to "stage only the sandbox" — that still runs Phase 1 and therefore CLI-pairs the agent. --- docs/operator-runbook-wire.md | 77 ++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 32 deletions(-) diff --git a/docs/operator-runbook-wire.md b/docs/operator-runbook-wire.md index 73b5db0a..da84688e 100644 --- a/docs/operator-runbook-wire.md +++ b/docs/operator-runbook-wire.md @@ -80,38 +80,51 @@ test** above. ## Path A — Web app (the master authorizes through the UI) -The parent-control web app is the master's console: it **vaults the key** and **pairs -+ authorizes the agent** through the real daemon → broker/worker chain (no mock data). - -1. **Start the stack** (builds the daemon + MCP if needed; sources - `scripts/operator-workstation.env` so the daemon inherits the cred/vault/memory - env — `VAULT_ROLE_ARN`, `AGENTKEYS_WORKER_CRED_URL`, …): - ```bash - bash dev.sh - # [daemon] http://localhost:3114 [mcp] http://localhost:18088 [ui] http://localhost:3113 - ``` -2. **Onboard the master** — open and complete onboarding - (email magic-link verify → on-chain master register). You land on the dashboard - with a live master session (the daemon starts **unseeded** — onboarding is what - authorizes its cap-mint). -3. **Vault the LLM key** — go to **credentials**, enter `openrouter` + your OpenRouter - API key, **⊕ store**. This POSTs `/v1/master/credentials/store` → cap-mint - (cred-store) → per-actor STS → cred worker → encrypt + S3 - `bots//credentials/openrouter.enc`. The table shows - `openrouter · ai-services · cred:openrouter`. -4. **Pair + authorize the agent** — the agent (in the sandbox) shows a one-time - pairing code (`agentkeys-daemon --request-pairing`; this is the §10.2 **agent - side**). Go to **pairing** → paste the code + a label → **⊕ claim** → review the - device + requested scope (incl. `cred:openrouter` + `memory:`) → **accept - pairing · Touch ID**. That one approval submits `registerAgentDevice` + the scope - grants on-chain (#214 / arch §10.2). -5. **Run the agent side** — the sandbox agent fetches its authorized key + memory and - wires Hermes; verify per **Verifying it worked**, below. (The fully-automated - agent side is the harness — Path B / the fastest test.) - -> **What's wired vs. pending (be honest with yourself when testing):** the web -> *credentials* vault (step 3) and the *pairing* authorize (step 4) are **real + -> on-chain today**. The final hop — the agent fetching **this** vaulted key with +The parent-control web app is **only the master's console** — it vaults the key and +claims an agent's pairing in the browser. It does **not** provision the agent device: +the agent runs in the **sandbox** and needs the compiled `agentkeys` / +`agentkeys-daemon` / `agentkeys-mcp-server` **cross-built for the sandbox's Linux arch +and uploaded** there (the sandbox is aarch64/x86 Linux, not your Mac), plus Hermes — +the same agent-side machinery Path B's harness provisions (its Phase 1 does the +`target/sandbox-linux` cross-build in an arm64 rust container → `sbx_put` to the +sandbox's `~/.local/bin`; see **Prerequisites**). + +**Start the master's console:** +```bash +bash dev.sh # [daemon] :3114 [mcp] :18088 [ui] :3113 — sources operator-workstation.env so the daemon has the cred/vault env +``` +Open and complete onboarding (email magic-link → on-chain +master register); you land on the dashboard with a live master session (the daemon +starts unseeded — onboarding authorizes its cap-mint). + +**A. Vault the LLM key — fully standalone, no sandbox needed.** Go to **credentials**, +enter `openrouter` + your key, **⊕ store** → `/v1/master/credentials/store` → cap-mint +(cred-store) → per-actor STS → cred worker → S3 `bots//credentials/openrouter.enc`. +The table shows `openrouter · ai-services · cred:openrouter`. This is the clean, +self-contained web piece. + +**B. Pair + authorize an agent (#214) — needs the agent side in the sandbox.** The +**pairing** page claims a one-time code the agent *shows*; for the agent to show one, +its binaries must be in the sandbox and `agentkeys-daemon --request-pairing` running +there. ⚠️ **The harness does NOT cleanly stage "binaries only":** `phase1-wire-demo.sh`'s +Phase 1 cross-builds + uploads the binaries **and then pairs the agent via the CLI** +(Phase P = `agentkeys agent claim`, which lives *inside* Phase 1) — so running it leaves +the agent already CLI-paired, with nothing for the web page to claim. To drive the **web** +claim end-to-end today you orchestrate the agent side by hand: cross-build + upload the +binaries (Appendix B / the Phase-1 mechanism), run `agentkeys-daemon --request-pairing` +in the sandbox to open a request, then **pairing** → paste the code → review scope +(`cred:openrouter` + `memory:`) → **accept · Touch ID** (submits `registerAgentDevice` ++ the grants on-chain). A one-command web-pairing flow isn't wired yet. + +**C. Agent fetches + runs on the vault key.** The end-to-end (agent fetches its +authorized key + memory, wires Hermes, runs) is proven headless by the **fastest test** +(`cred-wire-demo.sh`); the operator-interactive agent side is **Path B**. + +> **What's wired vs. pending (be honest with yourself when testing):** the web **vault** +> (A) is real + standalone today; the web **pairing claim** (B) is the real #214 +> master-side route but needs a manually-opened agent request (the harness CLI-pairs, so +> it doesn't drive the web claim — and the binaries must be cross-built + uploaded to the +> sandbox first). The final hop — the agent fetching **this** vaulted key with > **its own** (actor = agent) identity — needs the master to provision the key into > the **agent's** vault prefix (`bots//…`), which needs dual bearers > (operator session for the cap-mint + agent session for the STS tag). That From 0356793b64b32fb3cee5ace6b5330839588dc47d Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 15:59:47 +0800 Subject: [PATCH 07/85] docs+harness: #216 make wire runbook Path A / Path B fully independent + add sandbox-build-push.sh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the runbook treated Path A as leaning on Path B's harness for the agent side. Now each path is a self-contained quick-start. - NEW harness/sandbox-build-push.sh — Path A's standalone "compile agentkeys + push to the sandbox" command. Cross-builds the 3 binaries (agentkeys / -mcp-server / -daemon) for the sandbox's aarch64-Linux arch in the SAME cached arm64 builder image + cargo volumes phase1-wire-demo uses (warm tree re-pushes in seconds), uploads them to ~/.local/bin. Build + push ONLY — never pairs/wires. Re-run after any local change so the in-sandbox agent runs current source. VERIFIED live: pushed to the sandbox, and `agentkeys cred --help` there confirms the current #216 source. - operator-runbook-wire.md restructured: "Two independent paths — pick one" with BRIEF quick-starts for each (Path A = sandbox-build-push.sh + dev.sh + 3 UI actions; Path B = one phase1-wire-demo command) + a "neither path" headless check (cred-wire-demo). Path A details now use sandbox-build-push.sh (dropped the phase1-wire dependence + the now-moot "harness bundles pairing" caveat); kept the honest #214 wired-vs-pending note. - keep-docs-in-sync: harness/CLAUDE.md inventory + operator-runbook-harness.md. --- docs/operator-runbook-harness.md | 1 + docs/operator-runbook-wire.md | 114 ++++++++++++++++++------------- harness/CLAUDE.md | 1 + harness/sandbox-build-push.sh | 83 ++++++++++++++++++++++ 4 files changed, 153 insertions(+), 46 deletions(-) create mode 100755 harness/sandbox-build-push.sh diff --git a/docs/operator-runbook-harness.md b/docs/operator-runbook-harness.md index 64ab39a9..ad456765 100644 --- a/docs/operator-runbook-harness.md +++ b/docs/operator-runbook-harness.md @@ -198,6 +198,7 @@ Steps 11-12 sign STS creds AS the agent → they need the agent's key. Three rol - **`erc4337-master-e8.sh`** — standalone #164 mechanism smoke (passkey-only master mutation, green on mainnet). - **`cred-fetch-demo.sh`** — **#216 agent-side vaulted-key fetch, real e2e.** A master vaults a probe credential via the daemon, then the agent fetches it back with `agentkeys cred fetch`, asserting the exact secret round-trips through the live cap-mint → STS → cred worker → decrypt chain. Idempotent (fixed `cred-e2e-probe`), `--ci`-tolerant, real-only. Run: `bash harness/cred-fetch-demo.sh`. - **`cred-wire-demo.sh`** — **#216 agent-side wire, the FULL e2e.** Carries the cred-fetch through the Hermes wire: the master vaults the LLM key, the agent **cred-fetches** it, the harness **plants it into the sandbox Hermes** (`~/.hermes/.env` + `hermes config`), and **Hermes runs on the vault key** (real LLM smoke) — asserting the planted key == the vaulted key with **no `OPENROUTER_API_KEY` in the agent env**. The durable, no-Touch-ID complement to `phase1-wire-demo.sh` Phase 4.0b. Needs a reachable aiosandbox (`SANDBOX_URL`, default `http://localhost:8080`) with Hermes installed; the seed LLM key comes from `$OPENROUTER_API_KEY`/`$LLM_API_KEY` (skips the real-LLM smoke + vaults a probe if absent). `--ci`-tolerant, real-only. Run: `bash harness/cred-wire-demo.sh`. +- **`sandbox-build-push.sh`** — **Path-A binary provisioner (utility).** Cross-builds the agent binaries (`agentkeys` + `agentkeys-mcp-server` + `agentkeys-daemon`) for the sandbox's aarch64-Linux arch (cached arm64 builder image + shared cargo/target volumes — a warm tree re-pushes in seconds) and uploads them to the sandbox's `~/.local/bin`. **Build + push only** — no pairing/wire. Re-run after any local code change so the in-sandbox agent runs current source. Run: `bash harness/sandbox-build-push.sh` (override `SANDBOX_URL` for a remote sandbox). - **`web-memory-bootstrap.sh`** — issue #196 web-memory pre-flight; runbook [`operator-runbook-web-memory.md`](operator-runbook-web-memory.md). - **`openviking-sandbox-setup.sh`** — *optional, advanced.* Stands up **OpenViking as the memory engine** (Model B) and runs **INSIDE the aiosandbox**, not on the Mac. It **requires the agent to diff --git a/docs/operator-runbook-wire.md b/docs/operator-runbook-wire.md index da84688e..56868260 100644 --- a/docs/operator-runbook-wire.md +++ b/docs/operator-runbook-wire.md @@ -28,16 +28,43 @@ identical** either way; the paths differ only in how the master authorizes. over-cap action is **deterministically denied** (no LLM in the decision) and complies on revocation. -## The two paths (same agent side, different master side) +## Two independent paths — pick one -The master authorizes the agent two ways; the agent side (fetch → wire → run, in the -sandbox) is identical: +The two paths are **fully independent**: each is a complete way to test the wire, and +you never run one to set up the other. They differ only in **how the master +authorizes** the agent. Both assume the shared infra from **Fresh start** (below). -| | **Path A — Web app** | **Path B — CLI** | -|---|---|---| -| Vault the LLM key | parent-control UI → *credentials* | `agentkeys cred store` (or the daemon) | -| Pair + authorize the agent | parent-control UI → *pairing* (claim code → Touch ID) | `agentkeys agent claim` (phase1-wire Phase P) | -| Drives the agent side | the sandbox (harness) | the sandbox (harness) | +### Path A — Web app · quick start + +The master vaults + pairs through the parent-control UI; you push the agent binaries to +the sandbox yourself. + +```bash +bash harness/sandbox-build-push.sh # cross-build your CURRENT agentkeys → upload to the sandbox (re-run after any local change) +bash dev.sh # master web console: UI :3113 · daemon :3114 · mcp :18088 +``` + +Then in : **onboard** → **credentials** ⊕ store your LLM key → +**pairing** ⊕ claim the agent's code (Touch ID). To produce a code, run +`agentkeys-daemon --request-pairing` in the sandbox. Detail + caveats: **Path A — +details**, below. + +### Path B — CLI · quick start + +One harness automates the whole master **and** agent flow — cross-build + upload, §10.2 +pairing (Touch ID), wire, and the memory surprise: + +```bash +bash harness/phase1-wire-demo.sh --real --webauthn +``` + +Detail: **Path B walkthrough**, below. + +### Neither path — fastest headless check + +Not a path; a one-command sanity check that the whole stack works master-self (no UI, no +Touch ID): `bash harness/cred-wire-demo.sh` (vault → fetch → plant → Hermes runs on the +vault key). See **Fastest test**, below. ## Fastest test — one headless command (no UI, no Touch ID) @@ -78,16 +105,22 @@ master-authorizes-a-distinct-agent UX on top. Then pick **Path A** (web app) or **Path B** (CLI) below — or just run the **fastest test** above. -## Path A — Web app (the master authorizes through the UI) +## Path A — details The parent-control web app is **only the master's console** — it vaults the key and claims an agent's pairing in the browser. It does **not** provision the agent device: the agent runs in the **sandbox** and needs the compiled `agentkeys` / `agentkeys-daemon` / `agentkeys-mcp-server` **cross-built for the sandbox's Linux arch -and uploaded** there (the sandbox is aarch64/x86 Linux, not your Mac), plus Hermes — -the same agent-side machinery Path B's harness provisions (its Phase 1 does the -`target/sandbox-linux` cross-build in an arm64 rust container → `sbx_put` to the -sandbox's `~/.local/bin`; see **Prerequisites**). +and uploaded** (the sandbox is aarch64 Linux, not your Mac). One standalone command does +that — and it **only builds + pushes**, never pairs or wires: + +```bash +bash harness/sandbox-build-push.sh # cross-build (cached arm64 builder) → upload the 3 binaries to the sandbox's ~/.local/bin +``` + +Re-run it after any local code change so the in-sandbox agent runs your **current** +source (a warm tree re-pushes in seconds). Install Hermes in the sandbox too if absent +(see **Prerequisites**). **Start the master's console:** ```bash @@ -100,38 +133,26 @@ starts unseeded — onboarding authorizes its cap-mint). **A. Vault the LLM key — fully standalone, no sandbox needed.** Go to **credentials**, enter `openrouter` + your key, **⊕ store** → `/v1/master/credentials/store` → cap-mint (cred-store) → per-actor STS → cred worker → S3 `bots//credentials/openrouter.enc`. -The table shows `openrouter · ai-services · cred:openrouter`. This is the clean, -self-contained web piece. - -**B. Pair + authorize an agent (#214) — needs the agent side in the sandbox.** The -**pairing** page claims a one-time code the agent *shows*; for the agent to show one, -its binaries must be in the sandbox and `agentkeys-daemon --request-pairing` running -there. ⚠️ **The harness does NOT cleanly stage "binaries only":** `phase1-wire-demo.sh`'s -Phase 1 cross-builds + uploads the binaries **and then pairs the agent via the CLI** -(Phase P = `agentkeys agent claim`, which lives *inside* Phase 1) — so running it leaves -the agent already CLI-paired, with nothing for the web page to claim. To drive the **web** -claim end-to-end today you orchestrate the agent side by hand: cross-build + upload the -binaries (Appendix B / the Phase-1 mechanism), run `agentkeys-daemon --request-pairing` -in the sandbox to open a request, then **pairing** → paste the code → review scope -(`cred:openrouter` + `memory:`) → **accept · Touch ID** (submits `registerAgentDevice` -+ the grants on-chain). A one-command web-pairing flow isn't wired yet. - -**C. Agent fetches + runs on the vault key.** The end-to-end (agent fetches its -authorized key + memory, wires Hermes, runs) is proven headless by the **fastest test** -(`cred-wire-demo.sh`); the operator-interactive agent side is **Path B**. - -> **What's wired vs. pending (be honest with yourself when testing):** the web **vault** -> (A) is real + standalone today; the web **pairing claim** (B) is the real #214 -> master-side route but needs a manually-opened agent request (the harness CLI-pairs, so -> it doesn't drive the web claim — and the binaries must be cross-built + uploaded to the -> sandbox first). The final hop — the agent fetching **this** vaulted key with -> **its own** (actor = agent) identity — needs the master to provision the key into -> the **agent's** vault prefix (`bots//…`), which needs dual bearers -> (operator session for the cap-mint + agent session for the STS tag). That -> master-provisioning is **#214's authorization side and is not wired yet**. Until it -> lands, the **master-self** fastest test (`cred-wire-demo.sh`) is the end-to-end -> proof of fetch → wire → run, and Path B's Phase 4.0b fetches **vault-first with an -> env fallback**. (Tracked in [#216](https://github.com/litentry/agentKeys/issues/216).) +The table shows `openrouter · ai-services · cred:openrouter`. + +**B. Pair + authorize an agent (#214).** In the sandbox, open a pairing request with the +binary you pushed — `agentkeys-daemon --request-pairing` generates a fresh in-sandbox +device key and shows a one-time code (the §10.2 agent side). Then in the web UI: +**pairing** → paste the code + a label → **⊕ claim** → review the device + requested +scope (`cred:openrouter` + `memory:`) → **accept · Touch ID**. That one approval +submits `registerAgentDevice` + the scope grants on-chain. + +**C. Agent fetches + runs on the vault key.** With the agent paired + scoped, it fetches +its authorized key + memory and wires Hermes; verify per **Verifying it worked**, below. + +> **What's wired vs. pending (be honest when testing):** the web **vault** (A) and the +> **pairing claim** (B, #214) are real + on-chain today. The final hop — the agent +> fetching **this** vaulted key with **its own** (actor = agent) identity — needs the +> master to provision the key into the **agent's** vault prefix (`bots//…`), which +> needs dual bearers (operator session for the cap-mint + agent session for the STS tag). +> That master-provisioning is **#214's authorization side and is not wired yet**. Until it +> lands, the **master-self** fastest test (`cred-wire-demo.sh`) is the end-to-end proof of +> fetch → wire → run. (Tracked in [#216](https://github.com/litentry/agentKeys/issues/216).) ## Path B — CLI (the `phase1-wire-demo.sh` walkthrough) @@ -507,7 +528,8 @@ echo '{"tool_name":"x"}' | agentkeys hook audit ## Cross-references - [`harness/cred-wire-demo.sh`](../harness/cred-wire-demo.sh) — the **fastest test** (#216 full vault→fetch→wire→run e2e, headless) · [`harness/cred-fetch-demo.sh`](../harness/cred-fetch-demo.sh) — the cred store→fetch round-trip -- [`harness/phase1-wire-demo.sh`](../harness/phase1-wire-demo.sh) — Path B (the CLI walkthrough this runbook drives) · [`dev.sh`](../dev.sh) — Path A (the web-app dev stack: daemon + MCP + UI) +- **Path A:** [`harness/sandbox-build-push.sh`](../harness/sandbox-build-push.sh) — cross-build your current agentkeys → upload to the sandbox · [`dev.sh`](../dev.sh) — the master web console (daemon + MCP + UI) +- **Path B:** [`harness/phase1-wire-demo.sh`](../harness/phase1-wire-demo.sh) — the one-command CLI walkthrough (this runbook's Path B detail) - `agentkeys cred store --secret-env NAME` / `agentkeys cred fetch ` — the master-self vault + agent-fetch CLI primitives (#216) - [`docs/plan/phase1-wire-harness-test-plan.md`](plan/phase1-wire-harness-test-plan.md) — the action table + automation decisions - [Issue #216](https://github.com/litentry/agentKeys/issues/216) — agent-side vaulted-key wire · [Issue #214](https://github.com/litentry/agentKeys/issues/214) — master-side web pairing diff --git a/harness/CLAUDE.md b/harness/CLAUDE.md index 66d023e7..caf9923d 100644 --- a/harness/CLAUDE.md +++ b/harness/CLAUDE.md @@ -219,6 +219,7 @@ sandbox) is **GREEN**, never fail/incomplete. | `web-parity-demo.sh` | **phase 6 of `v2-demo.sh`** (NOT a standalone front door) — boots `agentkeys-daemon --ui-bridge` SEEDED with the master's J1 + device via the `--ui-bridge-seed-*` daemon seam (skips re-onboarding) + plants a **dedicated `webparity` probe ns** through the **web** endpoint `POST /v1/master/memory/plant`, **deleted on exit** (success or failure). A 200 proves the daemon's chain (cap-mint → STS → worker → S3) == the agent/harness chain — the web↔harness drift gate. **Step 4 (#214)** additionally polls `GET /v1/agent/pairing/pending` and asserts a well-formed `{requests:[…]}` — the master-side web-pairing route reaches the real broker rendezvous (the full claim→register e2e needs a live §10.2 agent request, exercised agent-side). Reuses phases 1-2's build/chain/broker/master (one daemon boot, no re-bootstrap); real-only. | `--from-step/--only-step N` / `--ci` | | `cred-fetch-demo.sh` | **#216 agent-side vaulted-key fetch, real e2e** (standalone). A master **vaults** a probe credential via the daemon (web path: cap-mint cred-store → STS → cred worker → S3), then the **agent** fetches it back with `agentkeys cred fetch` (CLI path: cap-mint cred-fetch → STS → cred worker → **decrypt**), asserting the EXACT secret round-trips. Proves the cred half of "the agent uses the key the master authorized it to use" (the Hermes wire is phase1-wire #216 Phase 4.0). Routes through the shared `agentkeys-backend-client` (no re-typed shapes, #204). Idempotent (a FIXED `cred-e2e-probe` service is overwritten each run — never accumulates); daemon killed on exit; real-only. | `--from-step/--only-step N` / `--ci` | | `cred-wire-demo.sh` | **#216 agent-side wire, the FULL e2e** (standalone, headless). Extends `cred-fetch-demo.sh` through the Hermes wire: master vaults the LLM key → **agent cred-fetches it** → **plant into the sandbox Hermes** (`~/.hermes/.env` + `hermes config set model.*`) → **Hermes runs on the vault key** (real LLM smoke), asserting the planted key == the vaulted key (sha) with **no `OPENROUTER_API_KEY` in the agent env**. The durable, no-Touch-ID complement to `phase1-wire-demo.sh` Phase 4.0b. Needs a reachable aiosandbox (`SANDBOX_URL`, default `:8080`) with Hermes installed. Idempotent (FIXED `openrouter` service; `.env` key-line rewritten not appended); daemon killed on exit; real-only. | `--from-step/--only-step N` / `--ci` | +| `sandbox-build-push.sh` | **Path-A binary provisioner (utility, not a stage demo).** Cross-builds the agent binaries (`agentkeys` + `agentkeys-mcp-server` + `agentkeys-daemon`) for the sandbox's aarch64-Linux arch in the cached arm64 builder image (sharing phase1-wire-demo.sh's exact `agentkeys-sandbox-builder` image + `agentkeys-sandbox-*` cargo/target volumes → a warm tree re-pushes in seconds) and uploads them to the sandbox's `~/.local/bin` via the file API. **Build + push ONLY** — it never pairs or wires (that's the master's job in the parent-control web UI). Re-run after any local code change so the in-sandbox agent runs current source. | `SANDBOX_URL` / `RUST_BUILD_IMAGE` / `CROSS_RUST_TOOLCHAIN` | (`scripts/setup-heima.sh` + `scripts/setup-broker-host.sh` are the canonical single-entry orchestrators for chain bring-up + the remote broker host; harness diff --git a/harness/sandbox-build-push.sh b/harness/sandbox-build-push.sh new file mode 100755 index 00000000..f4ed4d37 --- /dev/null +++ b/harness/sandbox-build-push.sh @@ -0,0 +1,83 @@ +#!/usr/bin/env bash +# harness/sandbox-build-push.sh — cross-build the agentkeys binaries for the +# sandbox (aarch64 Linux) and upload them to its ~/.local/bin. Run after a LOCAL +# code change so the in-sandbox agent runs your current source. +# +# Self-contained: it ONLY builds + pushes — it does NOT pair or wire (that's the +# master's job, done in the parent-control web UI). It shares phase1-wire-demo.sh's +# cached builder image + cargo volumes, so a warm tree re-pushes in seconds; the +# first run builds the deps image + a full cross-compile. +# +# bash harness/sandbox-build-push.sh # localhost sandbox +# SANDBOX_URL=http://host:8080 bash harness/sandbox-build-push.sh +set -euo pipefail +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +SANDBOX_URL="${SANDBOX_URL:-http://localhost:8080}" +RUST_BUILD_IMAGE="${RUST_BUILD_IMAGE:-rust:1.83-slim-bookworm}" +BUILDER_IMAGE="${BUILDER_IMAGE:-agentkeys-sandbox-builder:1.83-bookworm}" +CARGO_REGISTRY_VOL="${CARGO_REGISTRY_VOL:-agentkeys-sandbox-cargo-registry}" +CARGO_GIT_VOL="${CARGO_GIT_VOL:-agentkeys-sandbox-cargo-git}" +RUSTUP_VOL="${RUSTUP_VOL:-agentkeys-sandbox-rustup}" +CARGO_TARGET_VOL="${CARGO_TARGET_VOL:-agentkeys-sandbox-target}" +LINUX_TARGET_DIR="$REPO_ROOT/target/sandbox-linux" +BINS=(agentkeys agentkeys-mcp-server agentkeys-daemon) + +c() { [ -t 1 ] && printf '\033[%sm%s\033[0m' "$1" "$2" || printf '%s' "$2"; } +die() { printf '%s %s\n' "$(c '1;31' '✗')" "$1" >&2; exit 1; } +ok() { printf '%s %s\n' "$(c '1;32' '✓')" "$1"; } +command -v docker >/dev/null 2>&1 || die "docker required (cross-build for aarch64-linux)" +command -v jq >/dev/null 2>&1 || die "jq required" + +sbx() { curl -sS -m"${2:-20}" -X POST "$SANDBOX_URL/v1/shell/exec" -H 'content-type: application/json' \ + -d "$(jq -n --arg cmd "$1" '{command:$cmd}')"; } +sbx 'true' 6 | jq -e '.success==true' >/dev/null 2>&1 \ + || die "sandbox not reachable at $SANDBOX_URL — start it: docker run --security-opt seccomp=unconfined -d -p 8080:8080 ghcr.io/agent-infra/sandbox:latest" +HOME_SBX="$(sbx 'printf %s "$HOME"' 6 | jq -r '.data.output')" +[ -n "$HOME_SBX" ] && [ "$HOME_SBX" != "null" ] || die "could not resolve the sandbox \$HOME" + +# Builder image (rust + openssl deps baked) — build once if absent. +if ! docker image inspect "$BUILDER_IMAGE" >/dev/null 2>&1; then + printf '▸ building cached builder image %s (one-time)…\n' "$BUILDER_IMAGE" + docker build --platform linux/arm64 -t "$BUILDER_IMAGE" - </dev/null 2>&1 || die "could not build $BUILDER_IMAGE" +fi + +# Cross-build (named volumes → incremental; CARGO_TARGET_DIR is a named volume, +# never your host darwin target/; only the 3 binaries are copied out). Pin the +# toolchain to the host rustc — rust-toolchain.toml's `stable` floats, and a +# fresh-stable container breaks clean builds of some pre-release deps. +host_tc="$(rustc --version 2>/dev/null | awk '{print $2}')" +cross_tc="${CROSS_RUST_TOOLCHAIN:-${host_tc:-stable}}" +printf '▸ cross-building agentkeys (aarch64-linux, toolchain %s; first run slow, then incremental)…\n' "$cross_tc" +docker run --rm --platform linux/arm64 \ + -v "$REPO_ROOT":/src -w /src \ + -v "$CARGO_REGISTRY_VOL":/usr/local/cargo/registry \ + -v "$CARGO_GIT_VOL":/usr/local/cargo/git \ + -v "$RUSTUP_VOL":/usr/local/rustup \ + -v "$CARGO_TARGET_VOL":/cargo-target \ + -e CARGO_TARGET_DIR=/cargo-target \ + -e RUSTUP_TOOLCHAIN="$cross_tc" \ + "$BUILDER_IMAGE" \ + bash -c 'set -e + cargo build --release -p agentkeys-cli -p agentkeys-mcp-server -p agentkeys-daemon + mkdir -p /src/target/sandbox-linux/release + cp -f /cargo-target/release/agentkeys \ + /cargo-target/release/agentkeys-mcp-server \ + /cargo-target/release/agentkeys-daemon \ + /src/target/sandbox-linux/release/' +for b in "${BINS[@]}"; do [ -x "$LINUX_TARGET_DIR/release/$b" ] || die "build produced no $b"; done +ok "cross-built ${BINS[*]}" + +# Upload to ~/.local/bin (writable + on the sandbox PATH; the upload API is non-root). +sbx "mkdir -p '$HOME_SBX/.local/bin'" 6 >/dev/null +for b in "${BINS[@]}"; do + dst="$HOME_SBX/.local/bin/$b" + got="$(curl -sS -X POST "$SANDBOX_URL/v1/file/upload" -F "file=@$LINUX_TARGET_DIR/release/$b" -F "path=$dst" | jq -r '.data.file_path // "FAIL"')" + [ "$got" = "$dst" ] || die "$b upload failed (got: $got)" + sbx "chmod +x '$dst'" 6 >/dev/null + ok "$b → $dst" +done +printf '\n%s the sandbox runs your current agentkeys (%s/.local/bin). Open the agent pairing request there, then claim it in the web UI.\n' "$(c '1;32' 'DONE ·')" "$HOME_SBX" From e7ea8cfbc99fe0e521996f83f8fa18b45cf08061 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 17:43:55 +0800 Subject: [PATCH 08/85] =?UTF-8?q?docs:=20#216=20fix=20Path=20A=20pairing?= =?UTF-8?q?=20command=20=E2=80=94=20--request-pairing=20requires=20--broke?= =?UTF-8?q?r-url?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator hit `Error: --broker-url (or AGENTKEYS_BROKER_URL) required for --request-pairing` running the runbook command in the sandbox — my Path A command dropped the required flag. Verified the corrected invocation in the live sandbox (produces a pairing_code). Folded the complete, correct flow into Path A: 1. sandbox: agentkeys-daemon --request-pairing --broker-url https://broker.litentry.org → prints pairing_code + a state_file (the request_id lives in the file, not stdout) 2. web UI: claim the pairing_code (Touch ID) 3. sandbox: agentkeys-daemon --retrieve-pairing --request-id --broker-url … Matches phase1-wire-demo.sh Phase P.0/P.1b exactly. Fixed both the quick-start and the Path A — details command. --- docs/operator-runbook-wire.md | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/docs/operator-runbook-wire.md b/docs/operator-runbook-wire.md index 56868260..da371996 100644 --- a/docs/operator-runbook-wire.md +++ b/docs/operator-runbook-wire.md @@ -45,9 +45,9 @@ bash dev.sh # master web console: UI :3113 · daemon : ``` Then in : **onboard** → **credentials** ⊕ store your LLM key → -**pairing** ⊕ claim the agent's code (Touch ID). To produce a code, run -`agentkeys-daemon --request-pairing` in the sandbox. Detail + caveats: **Path A — -details**, below. +**pairing** ⊕ claim the agent's code (Touch ID). To produce a code, in the sandbox run +`agentkeys-daemon --request-pairing --broker-url https://broker.litentry.org`. Full flow +(incl. the post-claim `--retrieve-pairing`): **Path A — details**, below. ### Path B — CLI · quick start @@ -135,12 +135,27 @@ enter `openrouter` + your key, **⊕ store** → `/v1/master/credentials/store` (cred-store) → per-actor STS → cred worker → S3 `bots//credentials/openrouter.enc`. The table shows `openrouter · ai-services · cred:openrouter`. -**B. Pair + authorize an agent (#214).** In the sandbox, open a pairing request with the -binary you pushed — `agentkeys-daemon --request-pairing` generates a fresh in-sandbox -device key and shows a one-time code (the §10.2 agent side). Then in the web UI: -**pairing** → paste the code + a label → **⊕ claim** → review the device + requested -scope (`cred:openrouter` + `memory:`) → **accept · Touch ID**. That one approval -submits `registerAgentDevice` + the scope grants on-chain. +**B. Pair + authorize an agent (#214)** — the agent shows a code, you claim it in the +UI, the agent retrieves its session: + +1. **Sandbox** — open the request (a fresh in-sandbox device key; needs `--broker-url`; + prints a `pairing_code` + a state file holding the `request_id`): + ```bash + agentkeys-daemon --request-pairing --broker-url https://broker.litentry.org + # → {"pairing_code":"yXIN…","agent_address":"0x…","state_file":"~/.agentkeys/pairing-request-0x….json", …} + ``` +2. **Web UI** → **pairing** → paste the `pairing_code` + a label → **⊕ claim** → review + the device + requested scope (`cred:openrouter` + `memory:`) → **accept · Touch + ID** (submits `registerAgentDevice` + the scope grants on-chain). +3. **Sandbox** — after the master claims, the agent retrieves its session (`request_id` + is read from the state file the request wrote): + ```bash + # request_id from the newest pairing-request state file (use step 1's exact + # state_file path if you have several): + agentkeys-daemon --retrieve-pairing \ + --request-id "$(jq -r .request_id "$(ls -t ~/.agentkeys/pairing-request-*.json | head -1)")" \ + --broker-url https://broker.litentry.org + ``` **C. Agent fetches + runs on the vault key.** With the agent paired + scoped, it fetches its authorized key + memory and wires Hermes; verify per **Verifying it worked**, below. From bc9cbd9b9719c4a3e0f2ae3562ee36a3b3a786a4 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 17:51:39 +0800 Subject: [PATCH 09/85] feat: #216 default the agent pairing broker to prod (no --broker-url needed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `agentkeys-daemon --request-pairing` / `--retrieve-pairing` required --broker-url (or AGENTKEYS_BROKER_URL) and errored without it — friction for the Path-A operator running them in the sandbox. These commands ALWAYS need a broker, so default it: - main.rs: new `const DEFAULT_PAIRING_BROKER_URL = "https://broker.litentry.org"`; run_request_pairing + run_retrieve_pairing now `unwrap_or_else(default)` instead of erroring. `--broker-url` / `AGENTKEYS_BROKER_URL` still override (e.g. a test broker). Deliberately NOT a global arg default — `--ui-bridge`'s unset broker_url keeps its "fall back to pre-sourced AWS creds" meaning (the §191 pre-Stage-7 path). VERIFIED live: cross-built + pushed the daemon to the sandbox; `agentkeys-daemon --request-pairing` (no flag) now defaults to prod + opens a §10.2 request (code 9ZpC8nwu…) — the "--broker-url required" error is gone. Runbook (Path A quick-start + details) simplified to drop the flag; notes the prod default + the override. clippy -D warnings clean; daemon tests green. --- crates/agentkeys-daemon/src/main.rs | 21 +++++++++++++++------ docs/operator-runbook-wire.md | 14 +++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/crates/agentkeys-daemon/src/main.rs b/crates/agentkeys-daemon/src/main.rs index ff66d629..4af9edfb 100644 --- a/crates/agentkeys-daemon/src/main.rs +++ b/crates/agentkeys-daemon/src/main.rs @@ -653,12 +653,20 @@ fn acquire_pairing_lock(path: &str) -> anyhow::Result { /// `(request_id, device_pubkey, pop_sig)`); it is written only to the 0600 /// `state_file`, which `--retrieve-pairing` reads by default and from which an /// explicit workflow can source it. +/// Default broker for the agent-side pairing one-shots (`--request-pairing` / +/// `--retrieve-pairing`) when neither `--broker-url` nor `AGENTKEYS_BROKER_URL` is +/// given. These commands ALWAYS need a broker, so prod is the sane default (override +/// with the flag/env for a test broker). Deliberately NOT applied to `--ui-bridge`, +/// where an unset `broker_url` means "fall back to pre-sourced AWS creds" (§191). +const DEFAULT_PAIRING_BROKER_URL: &str = "https://broker.litentry.org"; + async fn run_request_pairing(args: Args) -> anyhow::Result<()> { use agentkeys_core::device_crypto::DeviceKey; - let broker_url = args.broker_url.clone().ok_or_else(|| { - anyhow::anyhow!("--broker-url (or AGENTKEYS_BROKER_URL) required for --request-pairing") - })?; + let broker_url = args + .broker_url + .clone() + .unwrap_or_else(|| DEFAULT_PAIRING_BROKER_URL.to_string()); let base = broker_url.trim_end_matches('/').to_string(); // Serialize the ENTIRE --request-pairing flow (K10 load/generate → guard → @@ -805,9 +813,10 @@ async fn run_request_pairing(args: Args) -> anyhow::Result<()> { async fn run_retrieve_pairing(args: Args) -> anyhow::Result<()> { use agentkeys_core::device_crypto::DeviceKey; - let broker_url = args.broker_url.clone().ok_or_else(|| { - anyhow::anyhow!("--broker-url (or AGENTKEYS_BROKER_URL) required for --retrieve-pairing") - })?; + let broker_url = args + .broker_url + .clone() + .unwrap_or_else(|| DEFAULT_PAIRING_BROKER_URL.to_string()); let base = broker_url.trim_end_matches('/').to_string(); // Load the device key FIRST: its device_pubkey keys the per-device state file diff --git a/docs/operator-runbook-wire.md b/docs/operator-runbook-wire.md index da371996..be602941 100644 --- a/docs/operator-runbook-wire.md +++ b/docs/operator-runbook-wire.md @@ -46,8 +46,8 @@ bash dev.sh # master web console: UI :3113 · daemon : Then in : **onboard** → **credentials** ⊕ store your LLM key → **pairing** ⊕ claim the agent's code (Touch ID). To produce a code, in the sandbox run -`agentkeys-daemon --request-pairing --broker-url https://broker.litentry.org`. Full flow -(incl. the post-claim `--retrieve-pairing`): **Path A — details**, below. +`agentkeys-daemon --request-pairing` (it defaults to the prod broker). Full flow (incl. +the post-claim `--retrieve-pairing`): **Path A — details**, below. ### Path B — CLI · quick start @@ -138,10 +138,11 @@ The table shows `openrouter · ai-services · cred:openrouter`. **B. Pair + authorize an agent (#214)** — the agent shows a code, you claim it in the UI, the agent retrieves its session: -1. **Sandbox** — open the request (a fresh in-sandbox device key; needs `--broker-url`; - prints a `pairing_code` + a state file holding the `request_id`): +1. **Sandbox** — open the request (a fresh in-sandbox device key; prints a `pairing_code` + + a state file holding the `request_id`). The broker **defaults to prod**; + `--broker-url` / `AGENTKEYS_BROKER_URL` overrides (e.g. a test broker): ```bash - agentkeys-daemon --request-pairing --broker-url https://broker.litentry.org + agentkeys-daemon --request-pairing # → {"pairing_code":"yXIN…","agent_address":"0x…","state_file":"~/.agentkeys/pairing-request-0x….json", …} ``` 2. **Web UI** → **pairing** → paste the `pairing_code` + a label → **⊕ claim** → review @@ -153,8 +154,7 @@ UI, the agent retrieves its session: # request_id from the newest pairing-request state file (use step 1's exact # state_file path if you have several): agentkeys-daemon --retrieve-pairing \ - --request-id "$(jq -r .request_id "$(ls -t ~/.agentkeys/pairing-request-*.json | head -1)")" \ - --broker-url https://broker.litentry.org + --request-id "$(jq -r .request_id "$(ls -t ~/.agentkeys/pairing-request-*.json | head -1)")" ``` **C. Agent fetches + runs on the vault key.** With the agent paired + scoped, it fetches From c02e782cfd92685ae2b1d39d80c887b0a11680c6 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 18:22:50 +0800 Subject: [PATCH 10/85] =?UTF-8?q?fix:=20#214=20web=20pairing=20register=20?= =?UTF-8?q?502=20=E2=80=94=20daemon=20couldn't=20find=20heima-agent-create?= =?UTF-8?q?.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `accept pairing · Touch ID` POSTed /v1/agent/pairing/register and got 502. Root cause: register_pairing derived the agent-register script as a SIBLING of --register-master-script, but the two are NOT co-located — dev.sh's master register is harness/scripts/heima-register-first-master.sh while heima-agent-create.sh lives in /scripts/. The sibling path (harness/scripts/heima-agent-create.sh) doesn't exist, so `bash ` exited non-zero → register_agent_device errored → 502. Fix: resolve heima-agent-create.sh from candidates — the sibling (co-located case) AND /scripts/ derived from the master script path — picking the first that exists; fail with a clear SERVICE_UNAVAILABLE message if neither is found. Verified: scripts/heima-agent-create.sh accepts exactly the args register_agent_device passes (--label/--agent-address/--actor-omni/--device-key-hash/--pop-sig, from-pubkey mode auto-detected), and a dry-run with the live agent details returns {"ok":true,"skipped":"already-registered"} → register_agent_device → Ok(None) → 200. The "no Touch ID" is expected (browser passkey UserOp is the E7-pending frontend item; the register goes through the daemon script shell-out today). clippy -D warnings clean; daemon tests green. --- crates/agentkeys-daemon/src/ui_bridge.rs | 32 ++++++++++++++++++------ 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/crates/agentkeys-daemon/src/ui_bridge.rs b/crates/agentkeys-daemon/src/ui_bridge.rs index 21f20bbd..f291a8fe 100644 --- a/crates/agentkeys-daemon/src/ui_bridge.rs +++ b/crates/agentkeys-daemon/src/ui_bridge.rs @@ -1964,14 +1964,30 @@ async fn register_pairing( "on-chain register not configured (--register-master-script) — cannot register the agent device", ); }; - let agent_script = match std::path::Path::new(&master_script).parent() { - Some(dir) => dir.join("heima-agent-create.sh"), - None => { - return pairing_err( - StatusCode::INTERNAL_SERVER_ERROR, - "cannot derive heima-agent-create.sh path", - ) - } + // `heima-agent-create.sh` canonically lives in `/scripts/`, while the + // master register script (`--register-master-script`) may be in + // `/harness/scripts/` (dev.sh) — so it is NOT always a sibling. Try the + // sibling first (co-located case), then `/scripts/` derived from the + // master script path. (#214 register-pairing path-mismatch fix — a missing + // script otherwise surfaced as a confusing 502 on `accept pairing`.) + let master_path = std::path::Path::new(&master_script); + let agent_script_candidates = [ + master_path.parent().map(|d| d.join("heima-agent-create.sh")), + master_path + .parent() + .and_then(|d| d.parent()) + .and_then(|d| d.parent()) + .map(|repo| repo.join("scripts").join("heima-agent-create.sh")), + ]; + let Some(agent_script) = agent_script_candidates + .into_iter() + .flatten() + .find(|p| p.exists()) + else { + return pairing_err( + StatusCode::SERVICE_UNAVAILABLE, + "heima-agent-create.sh not found (looked next to --register-master-script and in /scripts/)", + ); }; // Pull the authoritative binding from the broker (device fields, never the UI). let bindings = match agentkeys_cli::agent_admin::agent_pending_value(broker, &j1).await { From 7c3577074e6bc7f6f33332d5623e5f4af2d673c9 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 19:08:35 +0800 Subject: [PATCH 11/85] style: rustfmt the merged ui_bridge.rs (register path-fix block) --- crates/agentkeys-daemon/src/ui_bridge.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/agentkeys-daemon/src/ui_bridge.rs b/crates/agentkeys-daemon/src/ui_bridge.rs index f4b630eb..17f996dd 100644 --- a/crates/agentkeys-daemon/src/ui_bridge.rs +++ b/crates/agentkeys-daemon/src/ui_bridge.rs @@ -2137,7 +2137,9 @@ async fn register_pairing( // script otherwise surfaced as a confusing 502 on `accept pairing`.) let master_path = std::path::Path::new(&master_script); let agent_script_candidates = [ - master_path.parent().map(|d| d.join("heima-agent-create.sh")), + master_path + .parent() + .map(|d| d.join("heima-agent-create.sh")), master_path .parent() .and_then(|d| d.parent()) From 39c78ca125b40cf1e1a0ac06db6cf97806efb5eb Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 21:55:05 +0800 Subject: [PATCH 12/85] =?UTF-8?q?feat:=20#224=20pairing-card=20cross-verif?= =?UTF-8?q?ication=20=E2=80=94=20show=20device=5Fkey=5Fhash=20+=20full=20r?= =?UTF-8?q?equest=5Fid=20(slice=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The master pairing card showed a truncated "PAIR-CODE" that was actually the request_id (never the agent's one-time code), with no value the operator could cross-check against the agent — a confused-deputy surface (#224). Slice 1 surfaces the values that ARE on both sides today, with no broker change/deploy: - daemon (pending_binding_to_request): map the broker's device_key_hash → `deviceKeyHash` (+ short); keep `id` (the full request_id). The agent's `--request-pairing` already prints device_key_hash + D_pub, so these are the cross-verifiable identity. - agent (run_request_pairing): print device_key_hash on the human-facing line so the operator reads it off the agent to compare. - frontend (PairingRequest type + pairing card): replace the misleading "pair-code" with **device key hash · verify on agent** + **D_pub · verify on agent** (full) + **request id** (full handle). Operator confirms the device matches before accept · Touch ID. - test: pending_binding_maps_to_pairing_request asserts the full deviceKeyHash. Deferred to slice 2 (needs a broker change + deploy): created_at/expires_at timestamps on the card (the broker pending row has no timestamps today) and the `--force` supersede-prior-requests behavior. clippy/fmt clean; daemon tests + frontend typecheck green. --- apps/parent-control/app/_components/pairing.tsx | 14 ++++++++++---- apps/parent-control/app/_components/types.ts | 4 ++++ crates/agentkeys-daemon/src/main.rs | 3 ++- crates/agentkeys-daemon/src/ui_bridge.rs | 15 +++++++++++++++ 4 files changed, 31 insertions(+), 5 deletions(-) diff --git a/apps/parent-control/app/_components/pairing.tsx b/apps/parent-control/app/_components/pairing.tsx index dcdfd32c..1cde35db 100644 --- a/apps/parent-control/app/_components/pairing.tsx +++ b/apps/parent-control/app/_components/pairing.tsx @@ -98,12 +98,18 @@ export function PairingPage({
{req.runtime}
-
pair-code
-
{req.pairCode}
+ {/* #224 — verify-on-agent: the one-time pairing code is consumed at + claim, so the operator confirms the DEVICE instead. device_key_hash + + D_pub are both printed by the agent's `--request-pairing`; they + must match here before approving. request id is the master handle. */} +
device key hash · verify on agent
+
{req.deviceKeyHash || req.deviceKeyHashShort}
+
D_pub · verify on agent
+
{req.dpubFull || req.dpub}
+
request id
+
{req.id}
derivation
O_master{req.derivation}
-
D_pub
-
{req.dpub}
diff --git a/apps/parent-control/app/_components/types.ts b/apps/parent-control/app/_components/types.ts index 5b367470..4bdba280 100644 --- a/apps/parent-control/app/_components/types.ts +++ b/apps/parent-control/app/_components/types.ts @@ -91,6 +91,10 @@ export interface PairingRequest { runtime: string; dpub: string; dpubFull: string; + // #224 — the cross-verifiable device identity: the agent's `--request-pairing` + // prints `device_key_hash`, so the operator confirms it matches before approving. + deviceKeyHash: string; + deviceKeyHashShort: string; pairCode: string; derivation: string; requested: RequestedPerm[]; diff --git a/crates/agentkeys-daemon/src/main.rs b/crates/agentkeys-daemon/src/main.rs index db49a63c..a9ad10f6 100644 --- a/crates/agentkeys-daemon/src/main.rs +++ b/crates/agentkeys-daemon/src/main.rs @@ -783,7 +783,8 @@ async fn run_request_pairing(args: Args) -> anyhow::Result<()> { info!( target: "agentkeys.daemon.init", device = %device_pubkey, - "agentkeys-daemon opened §10.2 pairing request — show this code to your owner to claim: {pairing_code}" + device_key_hash = %device_key_hash, + "agentkeys-daemon opened §10.2 pairing request — show your owner the code to claim: {pairing_code}; they cross-check device_key_hash={device_key_hash} on the master before approving (#224)" ); // Machine artifact on STDOUT (logs are on stderr). The owner reads diff --git a/crates/agentkeys-daemon/src/ui_bridge.rs b/crates/agentkeys-daemon/src/ui_bridge.rs index 17f996dd..27bcaa0e 100644 --- a/crates/agentkeys-daemon/src/ui_bridge.rs +++ b/crates/agentkeys-daemon/src/ui_bridge.rs @@ -1975,6 +1975,7 @@ fn pending_binding_to_request(b: &serde_json::Value) -> serde_json::Value { let request_id = field("request_id"); let label = field("label"); let device_pubkey = field("device_pubkey"); + let device_key_hash = field("device_key_hash"); let pop_sig = field("pop_sig"); let requested_scope = field("requested_scope"); // char-safe head…tail elision for long hex handles. @@ -2013,6 +2014,14 @@ fn pending_binding_to_request(b: &serde_json::Value) -> serde_json::Value { "runtime": "hermes", "dpub": short(&device_pubkey), "dpubFull": device_pubkey, + // #224: the agent's one-time pairing code is consumed at claim, so the + // master verifies the request against the DEVICE instead — `deviceKeyHash` + // (+ `dpubFull`) are both printed by the agent's `--request-pairing`, so the + // operator cross-checks them before `accept · Touch ID`. `id` (above) is the + // full request_id (the master-side handle). `pairCode` is kept only for + // back-compat (it was the truncated request_id, never the agent's code). + "deviceKeyHash": device_key_hash.clone(), + "deviceKeyHashShort": short(&device_key_hash), "pairCode": short(&request_id), "derivation": format!("//{label}"), "requested": requested, @@ -3854,6 +3863,7 @@ mod tests { "label": "demo-agent", "requested_scope": "memory:travel,memory:family", "device_pubkey": "0x04aabbccddeeff00112233445566778899aabbcc", + "device_key_hash": "0x6d02e352b9bd71d3aa35677c35492bfdc39bacda89cc7d0506d31e2754abf2a5", "pop_sig": "0xsignaturedeadbeef0011223344556677", }); let pr = pending_binding_to_request(&row); @@ -3861,6 +3871,11 @@ mod tests { assert_eq!(pr["agent"], "demo-agent"); assert_eq!(pr["derivation"], "//demo-agent"); assert_eq!(pr["dpubFull"], "0x04aabbccddeeff00112233445566778899aabbcc"); + // #224 — the cross-verifiable device identity must be surfaced full. + assert_eq!( + pr["deviceKeyHash"], + "0x6d02e352b9bd71d3aa35677c35492bfdc39bacda89cc7d0506d31e2754abf2a5" + ); let requested = pr["requested"].as_array().expect("requested is an array"); assert_eq!(requested.len(), 2, "two scope tokens"); assert_eq!(requested[0]["cap"], "memory"); From 1b3f9039012ad022eb88ef7c6e3cd503a761a9e7 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 22:09:35 +0800 Subject: [PATCH 13/85] =?UTF-8?q?ui:=20#224=20relabel=20pairing=20card=20D?= =?UTF-8?q?=5Fpub=20=E2=86=92=20'device=20public=20address=20=C2=B7=20veri?= =?UTF-8?q?fy=20on=20agent'?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/parent-control/app/_components/pairing.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/parent-control/app/_components/pairing.tsx b/apps/parent-control/app/_components/pairing.tsx index 1cde35db..cb6dd706 100644 --- a/apps/parent-control/app/_components/pairing.tsx +++ b/apps/parent-control/app/_components/pairing.tsx @@ -104,7 +104,7 @@ export function PairingPage({ must match here before approving. request id is the master handle. */}
device key hash · verify on agent
{req.deviceKeyHash || req.deviceKeyHashShort}
-
D_pub · verify on agent
+
device public address · verify on agent
{req.dpubFull || req.dpub}
request id
{req.id}
From a986ac12f1fd259e135c715a334227692a9bf238 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 22:52:25 +0800 Subject: [PATCH 14/85] ui: refresh paired-device list after accept so it shows without a manual reload acceptPairing did registerPairing + refreshPairing but never re-fetched the actor tree, so a freshly-registered agent only appeared in the device/permission views after the operator reloaded the page. Re-fetch listActors after a successful register (matches finishPairingCeremony), surfacing the paired device immediately. --- apps/parent-control/app/_components/App.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/parent-control/app/_components/App.tsx b/apps/parent-control/app/_components/App.tsx index 97c59221..dfd9e489 100644 --- a/apps/parent-control/app/_components/App.tsx +++ b/apps/parent-control/app/_components/App.tsx @@ -367,6 +367,11 @@ export function App() { } showToast(`Registered ${req.agent} on chain. Grant its scope next (Touch ID).`); await refreshPairing(); + // The newly-registered agent now exists in the actor tree — re-fetch it so the + // paired device appears in the device/permission views immediately, without the + // operator having to reload the page (matches finishPairingCeremony's refresh). + const a = await client.listActors(); + if (a.ok) setActors(a.data); }; const declinePairing = (id: string) => { setPairingRequests((prev) => prev.filter((r) => r.id !== id)); From a2ba5113a9f3899a402ac47052e19e4bdea2366f Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Sun, 7 Jun 2026 23:59:47 +0800 Subject: [PATCH 15/85] =?UTF-8?q?feat:=20#225=20E7=20=E2=80=94=20ERC-4337?= =?UTF-8?q?=20accept-batch=20callData=20builders=20(atomic=20P.2+P.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent-accept gate (#225 / #164 E7) lands the device binding (registerAgentDevice, P.2) and the scope grant (setScope, P.3) in ONE P256Account.executeBatch UserOp — one block, one K11 signature, atomic. This adds the pure callData encoders that the batch needs (the genuinely new primitive); the sponsored-UserOp envelope is already owned by the broker's sponsor.rs (#200 Stage A). New crates/agentkeys-core/src/erc4337.rs: - register_agent_device_calldata — registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes) - set_scope_calldata — setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32) - execute_batch_calldata — executeBatch(address[],uint256[],bytes[]) - accept_batch_calldata — the headline: executeBatch([register, setScope]); threads the agent's actor_omni into both inner calls so they can't disagree on which agent they bind. Hand-rolled ABI (no alloy/ethabi — matches sponsor.rs/audit::calldata style), reusing the public audit::calldata::selector so selectors never drift. Golden-tested byte-for-byte against foundry cast for all three: cast calldata "registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)" ... cast calldata "setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)" ... cast calldata "executeBatch(address[],uint256[],bytes[])" "[reg,scope]" "[0,0]" "[reg_cd,scope_cd]" fixtures committed under src/testdata/. cargo test + clippy green. First slice of #225; the submission client (#200 Stage B), the daemon wiring, the browser ceremony, and the on-chain cutover remain (tracked in #225). --- crates/agentkeys-core/src/erc4337.rs | 294 ++++++++++++++++++ crates/agentkeys-core/src/lib.rs | 1 + .../src/testdata/erc4337_execute_batch.hex | 1 + .../src/testdata/erc4337_register.hex | 1 + .../src/testdata/erc4337_set_scope.hex | 1 + 5 files changed, 298 insertions(+) create mode 100644 crates/agentkeys-core/src/erc4337.rs create mode 100644 crates/agentkeys-core/src/testdata/erc4337_execute_batch.hex create mode 100644 crates/agentkeys-core/src/testdata/erc4337_register.hex create mode 100644 crates/agentkeys-core/src/testdata/erc4337_set_scope.hex diff --git a/crates/agentkeys-core/src/erc4337.rs b/crates/agentkeys-core/src/erc4337.rs new file mode 100644 index 00000000..8d3b67a7 --- /dev/null +++ b/crates/agentkeys-core/src/erc4337.rs @@ -0,0 +1,294 @@ +//! ERC-4337 accept-UserOp callData builders (#225 / #164 E7). +//! +//! Pure ABI encoders for the inner calls of the **agent-accept batch** — the +//! single `executeBatch` UserOp that lands the device binding (P.2) and the scope +//! grant (P.3) atomically, in one block, gated by ONE master passkey (K11) +//! signature over the `userOpHash`: +//! +//! ```text +//! P256Account.executeBatch( +//! [SidecarRegistry, AgentKeysScope ], +//! [0, 0 ], +//! [registerAgentDevice(...), setScope(...) ]) +//! ``` +//! +//! These functions produce the raw bytes that become [`sponsor::PackedUserOp`]'s +//! `call_data` (the broker owns the sponsored-UserOp envelope; this owns the +//! inner intent). They are byte-exact with the deployed contracts +//! (`crates/agentkeys-chain/src/{SidecarRegistry,AgentKeysScope,P256Account}.sol`) +//! and golden-tested against `cast calldata` (see `tests`). +//! +//! Why a hand-rolled encoder (no `alloy`/`ethabi`): the repo keeps the EVM +//! surface dependency-free and byte-explicit — mirroring +//! `agentkeys-broker-server/src/sponsor.rs` and `audit/calldata.rs`, whose public +//! [`selector`] this reuses so selectors never drift. + +use crate::audit::calldata::selector; + +const WORD: usize = 32; + +/// Args for `SidecarRegistry.registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)` +/// — the P.2 device binding. `actor_omni` is also the agent's omni for the P.3 +/// scope grant, so [`accept_batch_calldata`] threads it into both calls. +#[derive(Clone, Debug)] +pub struct AgentRegister { + pub device_key_hash: [u8; 32], + pub operator_omni: [u8; 32], + pub actor_omni: [u8; 32], + pub link_code_redemption: Vec, + pub agent_pop_sig: Vec, +} + +/// Args for `AgentKeysScope.setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)` +/// — the P.3 scope grant. `services` are the signed `bytes32` service ids +/// (`memory:` / `cred:`); the caps mirror the on-chain `Scope`. +#[derive(Clone, Debug)] +pub struct ScopeGrant { + pub services: Vec<[u8; 32]>, + pub read_only: bool, + pub max_per_call: u128, + pub max_per_period: u128, + pub max_total: u128, + pub period_seconds: u32, +} + +fn word_u128(n: u128) -> [u8; 32] { + let mut w = [0u8; 32]; + w[16..].copy_from_slice(&n.to_be_bytes()); + w +} + +fn addr_word(a: &[u8; 20]) -> [u8; 32] { + let mut w = [0u8; 32]; + w[12..].copy_from_slice(a); + w +} + +fn bool_word(b: bool) -> [u8; 32] { + let mut w = [0u8; 32]; + w[31] = b as u8; + w +} + +/// ABI `bytes`: `len(32) ‖ data ‖ zero-pad to a 32-byte multiple`. +fn enc_bytes(b: &[u8]) -> Vec { + let pad = (WORD - (b.len() % WORD)) % WORD; + let mut out = Vec::with_capacity(WORD + b.len() + pad); + out.extend_from_slice(&word_u128(b.len() as u128)); + out.extend_from_slice(b); + out.resize(out.len() + pad, 0); + out +} + +/// `registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)` calldata (P.2). +pub fn register_agent_device_calldata(r: &AgentRegister) -> Vec { + let sel = selector("registerAgentDevice(bytes32,bytes32,bytes32,bytes,bytes)"); + let enc_link = enc_bytes(&r.link_code_redemption); + let enc_pop = enc_bytes(&r.agent_pop_sig); + // Head: dkh, op, actor (inline bytes32) + 2 offsets for the dynamic `bytes`. + let head = 5 * WORD; + let off_link = head; + let off_pop = head + enc_link.len(); + + let mut out = Vec::with_capacity(4 + head + enc_link.len() + enc_pop.len()); + out.extend_from_slice(&sel); + out.extend_from_slice(&r.device_key_hash); + out.extend_from_slice(&r.operator_omni); + out.extend_from_slice(&r.actor_omni); + out.extend_from_slice(&word_u128(off_link as u128)); + out.extend_from_slice(&word_u128(off_pop as u128)); + out.extend_from_slice(&enc_link); + out.extend_from_slice(&enc_pop); + out +} + +/// `setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)` calldata (P.3). +pub fn set_scope_calldata( + operator_omni: &[u8; 32], + agent_omni: &[u8; 32], + g: &ScopeGrant, +) -> Vec { + let sel = selector("setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)"); + // Head is 8 words; `services` is the only dynamic arg, its data follows the head. + let off_services = 8 * WORD; + + let mut out = Vec::new(); + out.extend_from_slice(&sel); + out.extend_from_slice(operator_omni); + out.extend_from_slice(agent_omni); + out.extend_from_slice(&word_u128(off_services as u128)); + out.extend_from_slice(&bool_word(g.read_only)); + out.extend_from_slice(&word_u128(g.max_per_call)); + out.extend_from_slice(&word_u128(g.max_per_period)); + out.extend_from_slice(&word_u128(g.max_total)); + out.extend_from_slice(&word_u128(g.period_seconds as u128)); + // Tail: services array — len ‖ each bytes32 element. + out.extend_from_slice(&word_u128(g.services.len() as u128)); + for s in &g.services { + out.extend_from_slice(s); + } + out +} + +/// `executeBatch(address[],uint256[],bytes[])` calldata for [`P256Account`] — runs +/// each `(dest[i], values[i], func[i])` call atomically (any inner revert reverts +/// the whole batch). `values` are wei (u128 covers every realistic call value; the +/// accept batch uses 0). +pub fn execute_batch_calldata(dest: &[[u8; 20]], values: &[u128], func: &[Vec]) -> Vec { + let sel = selector("executeBatch(address[],uint256[],bytes[])"); + + // address[] dest: len ‖ each address word. + let mut enc_dest = Vec::with_capacity(WORD * (1 + dest.len())); + enc_dest.extend_from_slice(&word_u128(dest.len() as u128)); + for a in dest { + enc_dest.extend_from_slice(&addr_word(a)); + } + + // uint256[] values: len ‖ each value word. + let mut enc_value = Vec::with_capacity(WORD * (1 + values.len())); + enc_value.extend_from_slice(&word_u128(values.len() as u128)); + for n in values { + enc_value.extend_from_slice(&word_u128(*n)); + } + + // bytes[] func: len ‖ offset words (relative to AFTER the len word) ‖ each bytes elem. + let elems: Vec> = func.iter().map(|f| enc_bytes(f)).collect(); + let mut enc_func = Vec::new(); + enc_func.extend_from_slice(&word_u128(func.len() as u128)); + let mut running = func.len() * WORD; + for e in &elems { + enc_func.extend_from_slice(&word_u128(running as u128)); + running += e.len(); + } + for e in &elems { + enc_func.extend_from_slice(e); + } + + // Head: 3 offsets (dest, values, func), each relative to the args start. + let head = 3 * WORD; + let off_dest = head; + let off_value = head + enc_dest.len(); + let off_func = head + enc_dest.len() + enc_value.len(); + + let mut out = Vec::with_capacity(4 + head + enc_dest.len() + enc_value.len() + enc_func.len()); + out.extend_from_slice(&sel); + out.extend_from_slice(&word_u128(off_dest as u128)); + out.extend_from_slice(&word_u128(off_value as u128)); + out.extend_from_slice(&word_u128(off_func as u128)); + out.extend_from_slice(&enc_dest); + out.extend_from_slice(&enc_value); + out.extend_from_slice(&enc_func); + out +} + +/// **The #225 headline** — the atomic accept batch as one `executeBatch` callData. +/// +/// Composes `registerAgentDevice` (P.2) + `setScope` (P.3) into a single +/// [`execute_batch_calldata`] over `[registry, scope]`. The agent's `actor_omni` +/// from the register IS the `agentOmni` of the scope grant, threaded here by +/// construction so the two inner calls can never disagree on which agent they +/// bind. The result is signed once (K11) as the master UserOp's `call_data`. +pub fn accept_batch_calldata( + registry: &[u8; 20], + scope: &[u8; 20], + reg: &AgentRegister, + grant: &ScopeGrant, +) -> Vec { + let register_cd = register_agent_device_calldata(reg); + let scope_cd = set_scope_calldata(®.operator_omni, ®.actor_omni, grant); + execute_batch_calldata( + &[*registry, *scope], + &[0u128, 0u128], + &[register_cd, scope_cd], + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b32(x: u8) -> [u8; 32] { + [x; 32] + } + + fn sample_register() -> AgentRegister { + AgentRegister { + device_key_hash: b32(0x11), + operator_omni: b32(0x22), + actor_omni: b32(0x33), + link_code_redemption: hex::decode("deadbeef").unwrap(), + agent_pop_sig: vec![0x55; 65], + } + } + + fn sample_grant() -> ScopeGrant { + ScopeGrant { + services: vec![b32(0xaa), b32(0xbb)], + read_only: true, + max_per_call: 1000, + max_per_period: 2000, + max_total: 0, + period_seconds: 86400, + } + } + + fn addr(last: u8) -> [u8; 20] { + let mut a = [0u8; 20]; + a[19] = last; + a + } + + // Golden vectors produced by foundry `cast calldata` for the exact same inputs + // (the authoritative ABI encoder); see the commit message for the commands. + fn norm(s: &str) -> String { + s.trim().trim_start_matches("0x").to_string() + } + const GOLDEN_REGISTER: &str = include_str!("testdata/erc4337_register.hex"); + const GOLDEN_SET_SCOPE: &str = include_str!("testdata/erc4337_set_scope.hex"); + const GOLDEN_EXECUTE_BATCH: &str = include_str!("testdata/erc4337_execute_batch.hex"); + + #[test] + fn register_agent_device_matches_cast() { + let got = hex::encode(register_agent_device_calldata(&sample_register())); + assert_eq!(got, norm(GOLDEN_REGISTER)); + } + + #[test] + fn set_scope_matches_cast() { + let got = hex::encode(set_scope_calldata(&b32(0x22), &b32(0x33), &sample_grant())); + assert_eq!(got, norm(GOLDEN_SET_SCOPE)); + } + + #[test] + fn accept_batch_matches_cast() { + // dest = [registry 0x..a1, scope 0x..a2], values = [0,0], + // func = [registerAgentDevice(...), setScope(...)] — the atomic P.2+P.3 batch. + let got = hex::encode(accept_batch_calldata( + &addr(0xa1), + &addr(0xa2), + &sample_register(), + &sample_grant(), + )); + assert_eq!(got, norm(GOLDEN_EXECUTE_BATCH)); + } + + #[test] + fn batch_is_atomic_pair_of_the_two_inner_calls() { + // The batch's func[] is exactly [register_cd, set_scope_cd] — the property the + // one-block win relies on (no third call can sneak in; both bind the same agent). + let reg = sample_register(); + let grant = sample_grant(); + let register_cd = register_agent_device_calldata(®); + let scope_cd = set_scope_calldata(®.operator_omni, ®.actor_omni, &grant); + let batch = accept_batch_calldata(&addr(0xa1), &addr(0xa2), ®, &grant); + // both inner callDatas appear verbatim inside the batch bytes. + assert!(find_subslice(&batch, ®ister_cd).is_some()); + assert!(find_subslice(&batch, &scope_cd).is_some()); + // setScope's agentOmni is the register's actor_omni (threaded by construction). + assert_eq!(&scope_cd[4 + 32..4 + 64], ®.actor_omni); + } + + fn find_subslice(haystack: &[u8], needle: &[u8]) -> Option { + haystack.windows(needle.len()).position(|w| w == needle) + } +} diff --git a/crates/agentkeys-core/src/lib.rs b/crates/agentkeys-core/src/lib.rs index 5b5926c4..b3e544a7 100644 --- a/crates/agentkeys-core/src/lib.rs +++ b/crates/agentkeys-core/src/lib.rs @@ -5,6 +5,7 @@ pub mod backend; pub mod chain_profile; pub mod clear_signing; pub mod device_crypto; +pub mod erc4337; pub mod init_flow; pub mod mock_client; pub mod otp; diff --git a/crates/agentkeys-core/src/testdata/erc4337_execute_batch.hex b/crates/agentkeys-core/src/testdata/erc4337_execute_batch.hex new file mode 100644 index 00000000..a356f9e6 --- /dev/null +++ b/crates/agentkeys-core/src/testdata/erc4337_execute_batch.hex @@ -0,0 +1 @@ +0x47e1da2a000000000000000000000000000000000000000000000000000000000000006000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000a100000000000000000000000000000000000000000000000000000000000000a20000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001e000000000000000000000000000000000000000000000000000000000000001649847ca9511111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333300000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000004deadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555500000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000164d8e9e3c6222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000000007d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000000000000000000002aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb00000000000000000000000000000000000000000000000000000000 diff --git a/crates/agentkeys-core/src/testdata/erc4337_register.hex b/crates/agentkeys-core/src/testdata/erc4337_register.hex new file mode 100644 index 00000000..0367ae9a --- /dev/null +++ b/crates/agentkeys-core/src/testdata/erc4337_register.hex @@ -0,0 +1 @@ +0x9847ca9511111111111111111111111111111111111111111111111111111111111111112222222222222222222222222222222222222222222222222222222222222222333333333333333333333333333333333333333333333333333333333333333300000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000e00000000000000000000000000000000000000000000000000000000000000004deadbeef000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000041555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555555500000000000000000000000000000000000000000000000000000000000000 diff --git a/crates/agentkeys-core/src/testdata/erc4337_set_scope.hex b/crates/agentkeys-core/src/testdata/erc4337_set_scope.hex new file mode 100644 index 00000000..e6ac7ba7 --- /dev/null +++ b/crates/agentkeys-core/src/testdata/erc4337_set_scope.hex @@ -0,0 +1 @@ +0xd8e9e3c6222222222222222222222222222222222222222222222222222222222222222233333333333333333333333333333333333333333333333333333333333333330000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000000000100000000000000000000000000000000000000000000000000000000000003e800000000000000000000000000000000000000000000000000000000000007d0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000151800000000000000000000000000000000000000000000000000000000000000002aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb From f255e32e7a18661ef1d5bc6cee9a6a2f4367b47b Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 8 Jun 2026 00:10:50 +0800 Subject: [PATCH 16/85] =?UTF-8?q?feat:=20#225=20E7=20=E2=80=94=20assemble?= =?UTF-8?q?=20the=20sponsored=20accept=20UserOp=20(broker=20composer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ties the two existing halves into one ready-to-sign PackedUserOperation: - intent: agentkeys_core::erc4337::accept_batch_calldata (the atomic executeBatch([registerAgentDevice, setScope]), P.2+P.3) - sponsorship: the broker EIP-191 co-signs the VerifyingPaymaster getHash (J1-gated Sybil gate = gas-free), via crate::sponsor (#200 Stage A). New crates/agentkeys-broker-server/src/sponsored_accept.rs: - AcceptUserOpParams — every chain-derived value (nonce/gas/fees/validity/addrs) is an explicit input (nothing hardcoded; caller reads them on-chain). - assemble_accept_userop(params, broker_sk) -> AssembledAcceptUserOp { user_op, user_op_hash, paymaster_get_hash }. Sets paymasterAndData[20:52] (the gas word) provisionally so paymaster_get_hash commits the limits the broker approves, then rebuilds paymasterAndData with the real co-sign appended; computes the userOpHash the master K11 signs. Pure (broker key only, no chain I/O). Broker-side because the paymaster co-sign needs the broker key; the daemon will call this via an endpoint and just K11-sign the returned userOpHash (the #200 division of labour). 3 unit tests: callData==accept batch + sender==master + empty account sig + deterministic hash; paymasterAndData layout + broker co-sign recovers to the broker EOA; grant change => userOpHash change. cargo test + clippy green. Slice 2 of #225. Next: the broker HTTP endpoint wrapping this + the daemon call + the Stage-B handleOps submit (cast-based, mirrors the E8 proof). Refs #225. --- crates/agentkeys-broker-server/src/lib.rs | 1 + .../src/sponsored_accept.rs | 281 ++++++++++++++++++ 2 files changed, 282 insertions(+) create mode 100644 crates/agentkeys-broker-server/src/sponsored_accept.rs diff --git a/crates/agentkeys-broker-server/src/lib.rs b/crates/agentkeys-broker-server/src/lib.rs index 967943e8..b31e72b7 100644 --- a/crates/agentkeys-broker-server/src/lib.rs +++ b/crates/agentkeys-broker-server/src/lib.rs @@ -11,6 +11,7 @@ pub mod metrics; pub mod oidc; pub mod plugins; pub mod sponsor; +pub mod sponsored_accept; pub mod state; pub mod storage; pub mod sts; diff --git a/crates/agentkeys-broker-server/src/sponsored_accept.rs b/crates/agentkeys-broker-server/src/sponsored_accept.rs new file mode 100644 index 00000000..63753e16 --- /dev/null +++ b/crates/agentkeys-broker-server/src/sponsored_accept.rs @@ -0,0 +1,281 @@ +//! #225 / #164 E7 — assemble the **sponsored agent-accept UserOp**. +//! +//! Ties the two halves that already exist into one complete, ready-to-sign +//! `PackedUserOperation`: +//! - the **intent** — `agentkeys_core::erc4337::accept_batch_calldata` (the atomic +//! `executeBatch([registerAgentDevice, setScope])`, P.2 + P.3); +//! - the **sponsorship** — the broker EIP-191 co-signs the `VerifyingPaymaster` +//! `getHash` (the J1-gated Sybil gate = gas-free), via [`crate::sponsor`]. +//! +//! Output: the op with `paymasterAndData` filled, plus the `userOpHash` the master +//! passkey (K11) signs and the `getHash` the broker signed. **Pure** (takes the +//! broker key, no chain I/O): the caller fetches the on-chain 2D nonce + gas/fee +//! params and supplies them; submission (`EntryPoint.handleOps`) is the I/O step +//! that consumes [`AssembledAcceptUserOp::user_op`] once the account signature is +//! attached. +//! +//! Division of labour (unchanged from #200): browser/daemon K11-signs the +//! `userOpHash`; the broker co-signs the paymaster `getHash`; submission is Stage B. + +use crate::sponsor::{assemble_paymaster_and_data, broker_cosign, pack_u128_pair, PackedUserOp}; +use agentkeys_core::erc4337::{accept_batch_calldata, AgentRegister, ScopeGrant}; +use anyhow::Result; +use k256::ecdsa::SigningKey; + +/// Everything the composer needs that isn't the broker key. Chain-derived values +/// (nonce, gas, fees, validity window, addresses) are inputs — nothing hardcoded; +/// the caller reads them on-chain and passes them in. +pub struct AcceptUserOpParams<'a> { + pub entry_point: [u8; 20], + pub chain_id: u64, + + /// The operator's ERC-4337 P-256 master account (the `sender`). + pub master_account: [u8; 20], + /// `SidecarRegistry` (target of the `registerAgentDevice` inner call). + pub registry: [u8; 20], + /// `AgentKeysScope` (target of the `setScope` inner call). + pub scope: [u8; 20], + /// EntryPoint v0.7 2D nonce for `master_account` (read on-chain by the caller). + pub nonce: [u8; 32], + + /// `verificationGasLimit(16) ‖ callGasLimit(16)` — use [`pack_u128_pair`]. + pub account_gas_limits: [u8; 32], + pub pre_verification_gas: [u8; 32], + /// `maxPriorityFeePerGas(16) ‖ maxFeePerGas(16)` — use [`pack_u128_pair`]. + pub gas_fees: [u8; 32], + + pub paymaster: [u8; 20], + pub paymaster_verification_gas_limit: u128, + pub paymaster_post_op_gas_limit: u128, + pub valid_until: u64, + pub valid_after: u64, + /// The broker EOA the `VerifyingPaymaster` trusts (recovers from the co-sign). + pub broker_signer: [u8; 20], + + pub register: &'a AgentRegister, + pub grant: &'a ScopeGrant, +} + +/// The assembled op + the two digests. `user_op.signature` is still empty — the +/// account (K11) signs `user_op_hash` and the caller sets it before submit. +pub struct AssembledAcceptUserOp { + pub user_op: PackedUserOp, + /// The account (master passkey / K11) signs THIS — `EntryPoint.getUserOpHash`. + pub user_op_hash: [u8; 32], + /// The broker signed THIS — `VerifyingPaymaster.getHash` (returned for audit). + pub paymaster_get_hash: [u8; 32], +} + +/// Assemble the sponsored accept UserOp: build the batch callData, co-sign the +/// paymaster, fill `paymasterAndData`, and compute the `userOpHash`. +/// +/// The paymaster `getHash` commits `paymasterAndData[20:52]` (the gas limits), so +/// we set those bytes BEFORE hashing — a provisional `paymaster ‖ gasWord` — then +/// rebuild `paymasterAndData` with the real broker signature appended. The two +/// always agree on the gas word, which is what the on-chain `getHash` re-derives. +pub fn assemble_accept_userop( + p: &AcceptUserOpParams, + broker_sk: &SigningKey, +) -> Result { + let call_data = accept_batch_calldata(&p.registry, &p.scope, p.register, p.grant); + + let mut user_op = PackedUserOp { + sender: p.master_account, + nonce: p.nonce, + init_code: Vec::new(), + call_data, + account_gas_limits: p.account_gas_limits, + pre_verification_gas: p.pre_verification_gas, + gas_fees: p.gas_fees, + paymaster_and_data: Vec::new(), + signature: Vec::new(), + }; + + // Provisional paymasterAndData = paymaster(20) ‖ gasWord(32); exposes [20:52] + // (the gas word) so paymaster_get_hash reads the limits the broker is approving. + let gas_word = pack_u128_pair( + p.paymaster_verification_gas_limit, + p.paymaster_post_op_gas_limit, + ); + let mut provisional = Vec::with_capacity(52); + provisional.extend_from_slice(&p.paymaster); + provisional.extend_from_slice(&gas_word); + user_op.paymaster_and_data = provisional; + + let paymaster_get_hash = user_op.paymaster_get_hash( + p.valid_until, + p.valid_after, + &p.paymaster, + &p.broker_signer, + p.chain_id, + ); + let broker_sig = broker_cosign(&paymaster_get_hash, broker_sk)?; + + // Final paymasterAndData with the real co-signature appended. + user_op.paymaster_and_data = assemble_paymaster_and_data( + &p.paymaster, + p.paymaster_verification_gas_limit, + p.paymaster_post_op_gas_limit, + p.valid_until, + p.valid_after, + &broker_sig, + )?; + + let user_op_hash = user_op.user_op_hash(&p.entry_point, p.chain_id); + + Ok(AssembledAcceptUserOp { + user_op, + user_op_hash, + paymaster_get_hash, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use agentkeys_core::device_crypto::{ecrecover_eip191, evm_address}; + use k256::ecdsa::VerifyingKey; + + fn b32(x: u8) -> [u8; 32] { + [x; 32] + } + + fn sample_register() -> AgentRegister { + AgentRegister { + device_key_hash: b32(0x11), + operator_omni: b32(0x22), + actor_omni: b32(0x33), + link_code_redemption: hex::decode("deadbeef").unwrap(), + agent_pop_sig: vec![0x55; 65], + } + } + + fn sample_grant() -> ScopeGrant { + ScopeGrant { + services: vec![b32(0xaa), b32(0xbb)], + read_only: true, + max_per_call: 1000, + max_per_period: 2000, + max_total: 0, + period_seconds: 86400, + } + } + + fn params<'a>( + reg: &'a AgentRegister, + grant: &'a ScopeGrant, + broker_signer: [u8; 20], + ) -> AcceptUserOpParams<'a> { + AcceptUserOpParams { + entry_point: [0x66; 20], + chain_id: 212_013, + master_account: [0x99; 20], + registry: { + let mut a = [0u8; 20]; + a[19] = 0xa1; + a + }, + scope: { + let mut a = [0u8; 20]; + a[19] = 0xa2; + a + }, + nonce: { + let mut n = [0u8; 32]; + n[31] = 7; + n + }, + account_gas_limits: pack_u128_pair(300_000, 200_000), + pre_verification_gas: { + let mut w = [0u8; 32]; + w[28..].copy_from_slice(&60_000u32.to_be_bytes()); + w + }, + gas_fees: pack_u128_pair(1_000_000_000, 2_000_000_000), + paymaster: [0x55; 20], + paymaster_verification_gas_limit: 80_000, + paymaster_post_op_gas_limit: 40_000, + valid_until: 9_999_999_999, + valid_after: 0, + broker_signer, + register: reg, + grant, + } + } + + #[test] + fn calldata_is_the_accept_batch_and_sender_is_the_master() { + let sk = SigningKey::random(&mut rand_core::OsRng); + let broker_addr = evm_address(&VerifyingKey::from(&sk)); + let broker_bytes: [u8; 20] = hex::decode(broker_addr.trim_start_matches("0x")) + .unwrap() + .try_into() + .unwrap(); + let reg = sample_register(); + let grant = sample_grant(); + let p = params(®, &grant, broker_bytes); + let out = assemble_accept_userop(&p, &sk).unwrap(); + + assert_eq!(out.user_op.sender, p.master_account); + // The callData is exactly the atomic accept batch. + assert_eq!( + out.user_op.call_data, + accept_batch_calldata(&p.registry, &p.scope, ®, &grant) + ); + // Signature is left for the account (K11) to fill. + assert!(out.user_op.signature.is_empty()); + // userOpHash is deterministic. + assert_eq!( + out.user_op_hash, + out.user_op.user_op_hash(&p.entry_point, p.chain_id) + ); + } + + #[test] + fn paymaster_and_data_carries_a_broker_cosign_over_the_get_hash() { + let sk = SigningKey::random(&mut rand_core::OsRng); + let broker_addr = evm_address(&VerifyingKey::from(&sk)); + let broker_bytes: [u8; 20] = hex::decode(broker_addr.trim_start_matches("0x")) + .unwrap() + .try_into() + .unwrap(); + let reg = sample_register(); + let grant = sample_grant(); + let p = params(®, &grant, broker_bytes); + let out = assemble_accept_userop(&p, &sk).unwrap(); + + // Layout: paymaster(20) ‖ vgl(16) ‖ postOp(16) ‖ validUntil(6) ‖ validAfter(6) ‖ sig(65). + let pad = &out.user_op.paymaster_and_data; + assert_eq!(pad.len(), 20 + 16 + 16 + 6 + 6 + 65); + assert_eq!(&pad[0..20], &p.paymaster); + // The trailing 65 bytes are the broker co-sign; it recovers to the broker + // EOA under the SAME EIP-191(getHash) the VerifyingPaymaster checks. + // Layout offsets: paymaster 0..20, vgl 20..36, postOp 36..52, + // validUntil 52..58, validAfter 58..64, sig 64..129. + let sig_hex = format!("0x{}", hex::encode(&pad[64..129])); + let recovered = ecrecover_eip191(&out.paymaster_get_hash, &sig_hex).unwrap(); + assert_eq!(recovered, broker_addr); + } + + #[test] + fn changing_the_grant_changes_the_user_op_hash() { + let sk = SigningKey::random(&mut rand_core::OsRng); + let broker_addr = evm_address(&VerifyingKey::from(&sk)); + let broker_bytes: [u8; 20] = hex::decode(broker_addr.trim_start_matches("0x")) + .unwrap() + .try_into() + .unwrap(); + let reg = sample_register(); + let grant_a = sample_grant(); + let mut grant_b = sample_grant(); + grant_b.read_only = false; // a different scope ⇒ different intent ⇒ different hash. + + let h_a = assemble_accept_userop(¶ms(®, &grant_a, broker_bytes), &sk) + .unwrap() + .user_op_hash; + let h_b = assemble_accept_userop(¶ms(®, &grant_b, broker_bytes), &sk) + .unwrap() + .user_op_hash; + assert_ne!(h_a, h_b); + } +} From eb3d1cfe7f4310008d81ab2c75e2b7e79708955f Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 8 Jun 2026 00:35:28 +0800 Subject: [PATCH 17/85] =?UTF-8?q?feat:=20#225=20E7=20=E2=80=94=20accept-fl?= =?UTF-8?q?ow=20wire=20types=20(backend-client,=20#204=20one-owner)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the daemon<->broker protocol for the on-chain K11-gated accept, in the ONE owner crate per the #204 rule (the daemon deps backend-client; the broker mirrors these shapes server-side, pinned by the frozen key-set tests): - BuildAcceptUserOpRequest — POST /v1/accept/build (J1_master): register fields (device_key_hash, agent_pop_sig, link_code_redemption) + the granted scope (services + u128 caps as wire-safe decimal strings + period_seconds). - WireUserOp — ERC-4337 v0.7 PackedUserOperation, hex per field; mirrors broker sponsor::PackedUserOp. The daemon fills with the master K11 assertion over user_op_hash. - BuildAcceptUserOpResponse — { user_op, user_op_hash, entry_point, chain_id }. - SubmitAcceptUserOpRequest / SubmitAcceptUserOpResponse — POST /v1/accept/submit → EntryPoint.handleOps (Stage B), returns { ok, tx_hash, block_number }. Fixtures regenerated via dump-protocol-fixtures + frozen key-set tests for the three request bodies (build_accept_userop_request, wire_user_op, submit_accept_userop_request). cargo test + clippy + fixture --check green. Slice 3 of #225. Next: the broker /v1/accept/{build,submit} handlers (mirror these shapes server-side, gate on J1, call assemble_accept_userop) + the daemon call + K11-sign. Refs #225. --- .../agentkeys-backend-client/src/fixtures.rs | 86 ++++++++++++++++++- .../agentkeys-backend-client/src/protocol.rs | 72 ++++++++++++++++ .../build_accept_userop_request.json | 15 ++++ .../submit_accept_userop_request.json | 13 +++ .../backend-protocol/wire_user_op.json | 11 +++ 5 files changed, 195 insertions(+), 2 deletions(-) create mode 100644 harness/fixtures/backend-protocol/build_accept_userop_request.json create mode 100644 harness/fixtures/backend-protocol/submit_accept_userop_request.json create mode 100644 harness/fixtures/backend-protocol/wire_user_op.json diff --git a/crates/agentkeys-backend-client/src/fixtures.rs b/crates/agentkeys-backend-client/src/fixtures.rs index 18ea0044..e2dfbc0f 100644 --- a/crates/agentkeys-backend-client/src/fixtures.rs +++ b/crates/agentkeys-backend-client/src/fixtures.rs @@ -18,8 +18,8 @@ use serde_json::{json, Value}; use crate::protocol::{ - AuditAppendV2, BrokerCapRequest, ConfigGetBody, ConfigPutBody, MemoryGetBody, MemoryPutBody, - ENVELOPE_VERSION, + AuditAppendV2, BrokerCapRequest, BuildAcceptUserOpRequest, ConfigGetBody, ConfigPutBody, + MemoryGetBody, MemoryPutBody, SubmitAcceptUserOpRequest, WireUserOp, ENVELOPE_VERSION, }; /// One canonical fixture: the on-disk file stem + the sample body. @@ -66,6 +66,33 @@ pub fn canonical_fixtures() -> Vec { intent_text: Some("".into()), intent_commitment: None, }; + let build_accept = BuildAcceptUserOpRequest { + operator_omni: "0x".into(), + actor_omni: "0x".into(), + device_key_hash: "0x".into(), + agent_pop_sig: "0x".into(), + link_code_redemption: "0x".into(), + services: vec!["memory:".into()], + read_only: true, + max_per_call: "0".into(), + max_per_period: "0".into(), + max_total: "0".into(), + period_seconds: 0, + }; + let wire_user_op = WireUserOp { + sender: "0x".into(), + nonce: "0x".into(), + init_code: "0x".into(), + call_data: "0x".into(), + account_gas_limits: "0x".into(), + pre_verification_gas: "0x".into(), + gas_fees: "0x".into(), + paymaster_and_data: "0x".into(), + signature: "0x".into(), + }; + let submit_accept = SubmitAcceptUserOpRequest { + user_op: wire_user_op.clone(), + }; vec![ Fixture { name: "cap_mint_request", @@ -91,6 +118,18 @@ pub fn canonical_fixtures() -> Vec { name: "audit_append_v2", body: serde_json::to_value(&audit).expect("audit serializes"), }, + Fixture { + name: "build_accept_userop_request", + body: serde_json::to_value(&build_accept).expect("build_accept serializes"), + }, + Fixture { + name: "wire_user_op", + body: serde_json::to_value(&wire_user_op).expect("wire_user_op serializes"), + }, + Fixture { + name: "submit_accept_userop_request", + body: serde_json::to_value(&submit_accept).expect("submit_accept serializes"), + }, ] } @@ -174,4 +213,47 @@ mod tests { ] ); } + + #[test] + fn build_accept_userop_request_keys_frozen() { + assert_eq!( + keys_of("build_accept_userop_request"), + vec![ + "actor_omni", + "agent_pop_sig", + "device_key_hash", + "link_code_redemption", + "max_per_call", + "max_per_period", + "max_total", + "operator_omni", + "period_seconds", + "read_only", + "services", + ] + ); + } + + #[test] + fn wire_user_op_keys_frozen() { + assert_eq!( + keys_of("wire_user_op"), + vec![ + "account_gas_limits", + "call_data", + "gas_fees", + "init_code", + "nonce", + "paymaster_and_data", + "pre_verification_gas", + "sender", + "signature", + ] + ); + } + + #[test] + fn submit_accept_userop_request_keys_frozen() { + assert_eq!(keys_of("submit_accept_userop_request"), vec!["user_op"]); + } } diff --git a/crates/agentkeys-backend-client/src/protocol.rs b/crates/agentkeys-backend-client/src/protocol.rs index f68df4bb..8983901f 100644 --- a/crates/agentkeys-backend-client/src/protocol.rs +++ b/crates/agentkeys-backend-client/src/protocol.rs @@ -316,6 +316,78 @@ pub struct RevokeResult { pub note: Option, } +// ── #225 / #164 E7 — on-chain K11-gated agent accept (sponsored executeBatch) ─ +// +// The accept becomes ONE P256Account.executeBatch UserOp that lands the device +// binding (P.2) + the scope grant (P.3) atomically, gated by one master K11 +// signature. Two broker endpoints, J1_master-gated: `build` assembles + co-signs +// the sponsored op and returns the userOpHash; the daemon K11-signs it; `submit` +// relays the signed op to `EntryPoint.handleOps`. The broker mirrors these shapes +// server-side (it doesn't depend on this crate); the frozen key-set tests in +// `crate::fixtures` pin them so the two sides can't drift. + +/// Daemon → broker `POST /v1/accept/build`. The granted scope (`services` + +/// caps) is what the master approved in the pairing UI; the register fields bind +/// the agent device. `operator_omni`/`actor_omni` are `0x`-omni +/// ([`normalize_omni_0x`]); the `u128` caps ride as decimal strings (wire-safe +/// past 2^53; `"0"` = unset). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildAcceptUserOpRequest { + pub operator_omni: String, + pub actor_omni: String, + pub device_key_hash: String, + pub agent_pop_sig: String, + pub link_code_redemption: String, + pub services: Vec, + pub read_only: bool, + pub max_per_call: String, + pub max_per_period: String, + pub max_total: String, + pub period_seconds: u32, +} + +/// ERC-4337 v0.7 `PackedUserOperation`, hex-encoded for the wire. Mirrors +/// `agentkeys_broker_server::sponsor::PackedUserOp`; the daemon fills `signature` +/// with the master's K11 assertion over `user_op_hash`, then returns the whole op +/// to `/v1/accept/submit`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireUserOp { + pub sender: String, + pub nonce: String, + pub init_code: String, + pub call_data: String, + pub account_gas_limits: String, + pub pre_verification_gas: String, + pub gas_fees: String, + pub paymaster_and_data: String, + pub signature: String, +} + +/// Broker → daemon response to `/v1/accept/build`. The master signs +/// `user_op_hash` (the `EntryPoint.getUserOpHash` of `user_op`) with K11. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildAcceptUserOpResponse { + pub user_op: WireUserOp, + pub user_op_hash: String, + pub entry_point: String, + pub chain_id: u64, +} + +/// Daemon → broker `POST /v1/accept/submit` — the K11-signed op (its `signature` +/// now carries the assertion) for `EntryPoint.handleOps` (Stage B). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmitAcceptUserOpRequest { + pub user_op: WireUserOp, +} + +/// Broker → daemon response to `/v1/accept/submit`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SubmitAcceptUserOpResponse { + pub ok: bool, + pub tx_hash: String, + pub block_number: String, +} + // ── shared protocol helpers (the omni-normalization bug site, centralized) ─── /// Build the signed cap **service** string for a memory namespace — diff --git a/harness/fixtures/backend-protocol/build_accept_userop_request.json b/harness/fixtures/backend-protocol/build_accept_userop_request.json new file mode 100644 index 00000000..bdd1875d --- /dev/null +++ b/harness/fixtures/backend-protocol/build_accept_userop_request.json @@ -0,0 +1,15 @@ +{ + "actor_omni": "0x", + "agent_pop_sig": "0x", + "device_key_hash": "0x", + "link_code_redemption": "0x", + "max_per_call": "0", + "max_per_period": "0", + "max_total": "0", + "operator_omni": "0x", + "period_seconds": 0, + "read_only": true, + "services": [ + "memory:" + ] +} diff --git a/harness/fixtures/backend-protocol/submit_accept_userop_request.json b/harness/fixtures/backend-protocol/submit_accept_userop_request.json new file mode 100644 index 00000000..ca307893 --- /dev/null +++ b/harness/fixtures/backend-protocol/submit_accept_userop_request.json @@ -0,0 +1,13 @@ +{ + "user_op": { + "account_gas_limits": "0x", + "call_data": "0x", + "gas_fees": "0x", + "init_code": "0x", + "nonce": "0x", + "paymaster_and_data": "0x", + "pre_verification_gas": "0x", + "sender": "0x", + "signature": "0x" + } +} diff --git a/harness/fixtures/backend-protocol/wire_user_op.json b/harness/fixtures/backend-protocol/wire_user_op.json new file mode 100644 index 00000000..f60120fe --- /dev/null +++ b/harness/fixtures/backend-protocol/wire_user_op.json @@ -0,0 +1,11 @@ +{ + "account_gas_limits": "0x", + "call_data": "0x", + "gas_fees": "0x", + "init_code": "0x", + "nonce": "0x", + "paymaster_and_data": "0x", + "pre_verification_gas": "0x", + "sender": "0x", + "signature": "0x" +} From d5973396cb26520b1018c7666f073e2d695df40e Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 8 Jun 2026 02:08:07 +0800 Subject: [PATCH 18/85] =?UTF-8?q?feat:=20#225=20E7=20=E2=80=94=20PackedUse?= =?UTF-8?q?rOp=E2=86=92wire=20conversion=20+=20/v1/accept/build=20response?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The connective piece the broker accept handler returns: convert the internal sponsor::PackedUserOp into the hex-encoded wire shape and shape the build body. crates/agentkeys-broker-server/src/sponsored_accept.rs: - WireUserOp — broker-side mirror of backend_client::protocol::WireUserOp (the broker doesn't dep that crate; frozen key-set tests on both sides pin them). - WireUserOp::from_packed — hex-0x each PackedUserOp field. - BuildAcceptResponse + AssembledAcceptUserOp::into_build_response — the /v1/accept/build body { user_op, user_op_hash, entry_point, chain_id }. 3 unit tests: every wire field round-trips back to the original bytes; the build response carries the accept-batch callData + the userOpHash + entry_point/chain_id; WireUserOp JSON keys match the backend-client frozen shape (server-side #204 pin). cargo test + clippy green. Slice 4 of #225. Next (the I/O layer, happy-path gated on a deployed P256Account master): the axum /v1/accept/{build,submit} routes — J1_master auth (mirror mint_cap) + eth_call operatorMasterWallet/getNonce + assemble_accept_userop + into_build_response; submit relays EntryPoint.handleOps. Refs #225. --- .../src/sponsored_accept.rs | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/crates/agentkeys-broker-server/src/sponsored_accept.rs b/crates/agentkeys-broker-server/src/sponsored_accept.rs index 63753e16..f7a32377 100644 --- a/crates/agentkeys-broker-server/src/sponsored_accept.rs +++ b/crates/agentkeys-broker-server/src/sponsored_accept.rs @@ -21,6 +21,11 @@ use crate::sponsor::{assemble_paymaster_and_data, broker_cosign, pack_u128_pair, use agentkeys_core::erc4337::{accept_batch_calldata, AgentRegister, ScopeGrant}; use anyhow::Result; use k256::ecdsa::SigningKey; +use serde::{Deserialize, Serialize}; + +fn hex0x(b: &[u8]) -> String { + format!("0x{}", hex::encode(b)) +} /// Everything the composer needs that isn't the broker key. Chain-derived values /// (nonce, gas, fees, validity window, addresses) are inputs — nothing hardcoded; @@ -130,6 +135,66 @@ pub fn assemble_accept_userop( }) } +/// Broker-side mirror of `agentkeys_backend_client::protocol::WireUserOp` — the +/// hex-encoded ERC-4337 `PackedUserOperation` on the `/v1/accept/*` wire. The +/// broker doesn't depend on `backend-client`; the frozen key-set test there + the +/// one below pin the two shapes together (same discipline as `BrokerCapRequest`). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WireUserOp { + pub sender: String, + pub nonce: String, + pub init_code: String, + pub call_data: String, + pub account_gas_limits: String, + pub pre_verification_gas: String, + pub gas_fees: String, + pub paymaster_and_data: String, + pub signature: String, +} + +impl WireUserOp { + pub fn from_packed(op: &PackedUserOp) -> Self { + Self { + sender: hex0x(&op.sender), + nonce: hex0x(&op.nonce), + init_code: hex0x(&op.init_code), + call_data: hex0x(&op.call_data), + account_gas_limits: hex0x(&op.account_gas_limits), + pre_verification_gas: hex0x(&op.pre_verification_gas), + gas_fees: hex0x(&op.gas_fees), + paymaster_and_data: hex0x(&op.paymaster_and_data), + signature: hex0x(&op.signature), + } + } +} + +/// Broker-side mirror of `BuildAcceptUserOpResponse` — the `/v1/accept/build` body +/// the daemon receives, then K11-signs `user_op_hash` and returns the filled +/// `user_op` to `/v1/accept/submit`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BuildAcceptResponse { + pub user_op: WireUserOp, + pub user_op_hash: String, + pub entry_point: String, + pub chain_id: u64, +} + +impl AssembledAcceptUserOp { + /// Shape the assembled op into the `/v1/accept/build` response body. + pub fn into_build_response( + &self, + entry_point: &[u8; 20], + chain_id: u64, + ) -> BuildAcceptResponse { + BuildAcceptResponse { + user_op: WireUserOp::from_packed(&self.user_op), + user_op_hash: hex0x(&self.user_op_hash), + entry_point: hex0x(entry_point), + chain_id, + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -278,4 +343,75 @@ mod tests { .user_op_hash; assert_ne!(h_a, h_b); } + + fn assembled() -> (AssembledAcceptUserOp, [u8; 20], u64, Vec) { + let sk = SigningKey::random(&mut rand_core::OsRng); + let broker_addr = evm_address(&VerifyingKey::from(&sk)); + let broker_bytes: [u8; 20] = hex::decode(broker_addr.trim_start_matches("0x")) + .unwrap() + .try_into() + .unwrap(); + let reg = sample_register(); + let grant = sample_grant(); + let p = params(®, &grant, broker_bytes); + let expected_calldata = accept_batch_calldata(&p.registry, &p.scope, ®, &grant); + let out = assemble_accept_userop(&p, &sk).unwrap(); + (out, p.entry_point, p.chain_id, expected_calldata) + } + + fn unhex(s: &str) -> Vec { + hex::decode(s.trim_start_matches("0x")).unwrap() + } + + #[test] + fn wire_user_op_round_trips_every_field() { + let (out, _, _, _) = assembled(); + let w = WireUserOp::from_packed(&out.user_op); + assert_eq!(unhex(&w.sender), out.user_op.sender); + assert_eq!(unhex(&w.nonce), out.user_op.nonce); + assert_eq!(unhex(&w.init_code), out.user_op.init_code); + assert_eq!(unhex(&w.call_data), out.user_op.call_data); + assert_eq!(unhex(&w.account_gas_limits), out.user_op.account_gas_limits); + assert_eq!( + unhex(&w.pre_verification_gas), + out.user_op.pre_verification_gas + ); + assert_eq!(unhex(&w.gas_fees), out.user_op.gas_fees); + assert_eq!(unhex(&w.paymaster_and_data), out.user_op.paymaster_and_data); + assert_eq!(unhex(&w.signature), out.user_op.signature); + } + + #[test] + fn build_response_carries_the_batch_calldata_and_hash() { + let (out, entry_point, chain_id, expected_calldata) = assembled(); + let resp = out.into_build_response(&entry_point, chain_id); + assert_eq!(unhex(&resp.user_op.call_data), expected_calldata); + assert_eq!(unhex(&resp.user_op_hash), out.user_op_hash); + assert_eq!(unhex(&resp.entry_point), entry_point); + assert_eq!(resp.chain_id, chain_id); + } + + #[test] + fn wire_user_op_keys_match_backend_client_shape() { + // Server-side half of the #204 pin: a broker-side rename trips here, the + // backend-client `wire_user_op_keys_frozen` test catches the client side. + let (out, _, _, _) = assembled(); + let v = serde_json::to_value(WireUserOp::from_packed(&out.user_op)).unwrap(); + let mut keys: Vec = v.as_object().unwrap().keys().cloned().collect(); + keys.sort(); + assert_eq!( + keys, + vec![ + "account_gas_limits", + "call_data", + "gas_fees", + "init_code", + "nonce", + "paymaster_and_data", + "pre_verification_gas", + "sender", + "signature", + ] + ); + } } From 30ed5705bd8d0c83712ad6a4204329190e3af787 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 8 Jun 2026 02:59:14 +0800 Subject: [PATCH 19/85] =?UTF-8?q?docs:=20#225=20E7=20=E2=80=94=20scope=20t?= =?UTF-8?q?he=20account-auth=20cutover=20+=20onboarding-as-account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The precise, idempotent spec for the live-mainnet cutover that unblocks the #225 e2e (PR #227's /v1/accept flow needs the master to BE a deployed P256Account, not the current EOA). docs/plan/chain/account-auth-cutover.md specifies: - The gap: registry/scope sources are account-auth in code (E3) but the LIVE bytecode is pre-E3; heima-bring-up's cast-code idempotency check skips the redeploy, so account-auth never goes live. - The consequence (loud): DeployAgentKeysV1 redeploys to NEW addresses → all on-chain state (master, agents, scopes, epoch, audit) resets → full re-bootstrap; demo breaks until re-bootstrapped. Operator-gated, announced, NOT in the plain flow. - 6 phases (pre-flight → redeploy v2 set FORCE_DEPLOY → redeploy P256AccountFactory → onboarding-as-account → re-bootstrap actors → code/doc updates → broker redeploy), each idempotent with explicit skip checks. - Idempotency strategy for a REDEPLOY (cast-code alone is insufficient since the old contracts also have code): a CUTOVER_DONE_ marker + a live setScope account-auth ABI capability probe. - The two scripts to implement (heima-cutover-account-auth.sh + heima-deploy-master-account.sh), the setup-heima.sh --cutover-account-auth wiring, the #201 env 3-file discipline, rollback (restore the .pre-cutover.bak env), and the arch.md §10/§12 + deployed-contracts.md sync owed at Phase 5. Refs #225. Scopes the cutover named in erc4337-master-account.md §3.1. --- docs/plan/chain/account-auth-cutover.md | 52 +++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 docs/plan/chain/account-auth-cutover.md diff --git a/docs/plan/chain/account-auth-cutover.md b/docs/plan/chain/account-auth-cutover.md new file mode 100644 index 00000000..eb3be3fe --- /dev/null +++ b/docs/plan/chain/account-auth-cutover.md @@ -0,0 +1,52 @@ +# Account-auth cutover + onboarding-as-account (the #225 / #164 E7 unblock) + +**Status:** scope/spec for the idempotent cutover script(s). Implements the cutover outlined in [`erc4337-master-account.md`](erc4337-master-account.md) §3.1 ("Cutover — coordinated redeploy, not yet done"). This doc is the precise procedure; the scripts (Phase wiring below) implement exactly this. **Nothing here has run on live mainnet yet** — it changes the contracts the current demo/harness depend on, so it is operator-gated and runs as a deliberate, announced action. + +## Why (the gap) + +`SidecarRegistry`/`AgentKeysScope` sources are **already account-auth** in code (E3: master writes gated by `msg.sender == operatorMasterWallet`, `setScopeWithWebauthn`→`setScope`, in-contract K11 + `scopeNonce` retired). But the **live deployed** registry/scope are the **pre-E3** bytecode — `heima-bring-up.sh`'s idempotency check (`cast code` on the stored addresses) sees code there and **skips** the redeploy, so the account-auth contracts never go live. The cutover forces the redeploy and re-points everything at the new addresses. Until it runs, `/v1/accept/build` (PR #227) would read an **EOA** from `operatorMasterWallet` and the sponsored UserOp's `sender` would be a non-account → `EntryPoint` rejects it. + +## ⚠️ Consequence — this is a redeploy to NEW addresses, i.e. a full re-bootstrap + +`DeployAgentKeysV1.s.sol` deploys the whole v2 set atomically (P256Verifier → K11Verifier → SidecarRegistry → AgentKeysScope → K3EpochCounter → CredentialAudit). Redeploying gives **new addresses with empty state** — the registered master device, every agent binding, every scope grant, the K3 epoch counter, and the audit history are **NOT migrated**. After cutover, the master + all agents must be **re-registered** and scopes **re-granted** (now via the account path). The current working demo breaks until re-bootstrapped. CI already skips first-master, so CI is unaffected. **Announce + schedule this; do not run it mid-demo.** + +## Procedure (idempotent; each phase pre-checks + short-circuits) + +Run via the new orchestrator `scripts/heima-cutover-account-auth.sh` (wired into `setup-heima.sh` as a NEW, explicitly-opt-in step — it is NOT part of a plain `setup-heima.sh` run because it is destructive; gate it behind `--cutover-account-auth`). All addresses are env-namespaced (`*_HEIMA` / `*_HEIMA_PASEO`) via `env_set`; nothing hardcoded. + +| Phase | Action | Idempotency check (skip when…) | +|---|---|---| +| **0. Pre-flight** | Confirm the local `SidecarRegistry.sol` exposes the account-auth shape (e.g. `setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)` selector present in the ABI, NOT `setScopeWithWebauthn`). Back up the current `*_ADDRESS_*` env values to `operator-workstation.env.pre-cutover.bak`. | backup file already exists | +| **1. Redeploy v2 set** | `FORCE_DEPLOY=1 bash scripts/heima-bring-up.sh` → new registry/scope/epoch/audit/verifiers; its `env_set` writes the new addresses. | a `CUTOVER_DONE_${PROFILE}=1` marker is set in env AND the live scope answers `setScope` (account-auth selector) — see Idempotency below | +| **2. Redeploy P256AccountFactory** | Deploy the E5-complete `P256AccountFactory` (embeds the recover()-capable `P256Account`); `env_set P256_ACCOUNT_FACTORY_ADDRESS_${PROFILE}`. | `cast code` on the stored factory AND its embedded account has `recover()` (selector probe) | +| **3. Onboarding-as-account** | `scripts/heima-deploy-master-account.sh`: from the master's enrolled K11 pubkey, `factory.getAddress(...)` → `cast code` → `createAccount(...)` if absent; fund the EntryPoint deposit (≥ ED); then `registerFirstMasterDevice(operatorOmni, account, …)` so `operatorMasterWallet[omni] == account`. | account already deployed + `operatorMasterWallet == account` | +| **4. Re-bootstrap actors** | Re-register each agent (`heima-agent-create.sh`) + re-grant scopes (`heima-scope-set.sh`, now `setScope` path) on the new contracts. | `isActive(deviceKeyHash)` / `getScope` already matches | +| **5. Code + doc updates** | `heima-scope-set.sh` `setScopeWithWebauthn`→`setScope`; `verify-heima-contracts.sh` (account-auth assertions); **arch.md §10/§12** (master = account; scope grant = `setScope`); the broker reads scope via env so no code change, just the new `SCOPE_CONTRACT_ADDRESS_*`. | files already at account-auth form (grep guard) | +| **6. Broker redeploy** | `bash scripts/setup-broker-host.sh --ref main` on the broker host → picks up new registry/scope addresses + the `sponsored_accept` module + (when landed) the `/v1/accept/*` routes. | broker already on the target ref + env | + +## Idempotency strategy (a redeploy is NOT a first-deploy) + +`heima-bring-up.sh`'s native check (`cast code` on the stored address) is **insufficient** here — the *old* contracts also have code, so it would skip. The cutover therefore gates on **two** signals, both written only after a successful Phase-1 redeploy: + +1. A `CUTOVER_DONE_${PROFILE_UC}=1` marker in `operator-workstation.env` (via `env_set`). +2. A **capability probe** on the live scope address: `cast call $SCOPE "setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)" …` succeeds (account-auth ABI) where the pre-cutover `setScopeWithWebauthn` ABI would not. The probe is the ground truth; the marker is the fast path. + +A re-run with both present logs `skip already-cut-over` and exits 0. `--force-cutover` clears the marker to redeploy intentionally. + +## Scripts to implement (the deliverable this spec defines) + +- `scripts/heima-cutover-account-auth.sh` — the Phase 0/1/2/5 orchestrator (idempotent, `ok`/`skip`/`fail` logging, `--force-cutover`, env-namespaced, no hardcoded values). Wired into `setup-heima.sh` behind `--cutover-account-auth` (destructive ⇒ opt-in, NOT in the plain flow — the one allowed exception to "plain = prod" because a redeploy resets state). +- `scripts/heima-deploy-master-account.sh` — Phase 3 (onboarding-as-account), idempotent, mirrors `harness/erc4337-master-e8.sh`'s `factory.createAccount` + EntryPoint-deposit steps. +- Phase 4 reuses the existing `heima-agent-create.sh` / `heima-scope-set.sh` (idempotent already). + +## Env discipline (HARD — any new key) + +`CUTOVER_DONE_*` and any new address key go into **BOTH** `scripts/operator-workstation.env` AND `scripts/operator-workstation.test.env` (with the `-test`/`_PASEO` variant), AND the CI env-materializer in `.github/workflows/harness-ci.yml`, AND each consuming harness script must `: "${NEW_KEY:=}"`-default it after sourcing. (Per the #201 env-discipline rule — a key in only one place breaks `--ci`.) + +## Rollback + +Restore `operator-workstation.env.pre-cutover.bak` over `operator-workstation.env` and redeploy the broker (`setup-broker-host.sh --ref main`). The old (pre-E3) contracts are still live at their original addresses with their original state intact, so reverting the env pointers fully restores the prior demo. (The new contracts are simply orphaned.) + +## arch.md sync (source-of-truth rule) + +When Phase 5 lands, update [`docs/arch.md`](../../arch.md) §10 (per-actor ceremonies — agent bind + scope grant now ride the account UserOp) and §12 (scope model — `setScope`, no in-contract K11), and move the §3.1 cutover note in `erc4337-master-account.md` from ⏭️ to ✅ with the new addresses recorded in [`deployed-contracts.md`](../../spec/deployed-contracts.md). From 1494982d72d451d5a103510edfb2223e5a132b55 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 8 Jun 2026 03:01:43 +0800 Subject: [PATCH 20/85] =?UTF-8?q?docs:=20#225=20=E2=80=94=20cutover=20spec?= =?UTF-8?q?=20reuses=20erc4337-register-master.sh;=20decouple=20master-as-?= =?UTF-8?q?account?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diligence correction to the cutover spec after finding the onboarding-as-account step already exists: - Phase 3 (onboarding-as-account) reuses the existing `erc4337-register-master.sh` (build+submit) — it already does factory.createAccount + EntryPoint-deposit + register-first-master-as-account, idempotently. Dropped the proposed (redundant) `heima-deploy-master-account.sh`; only ONE new script remains (the cutover orchestrator `heima-cutover-account-auth.sh`). - Decoupling finding (from that script's header): master-as-account is VIABLE on the LIVE pre-cutover registry (no EOA-only guard), so operatorMasterWallet[omni] can be the P256Account TODAY — no disruptive redeploy needed for that half. The cutover is only required for the accept batch's setScope (P.3): the live scope has setScopeWithWebauthn, not the msg.sender-gated setScope. So work can stage: register master-as-account now + exercise /v1/accept/build against it; do the registry/scope redeploy only when account-auth setScope is needed e2e. Refs #225. --- docs/plan/chain/account-auth-cutover.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docs/plan/chain/account-auth-cutover.md b/docs/plan/chain/account-auth-cutover.md index eb3be3fe..2c88c4da 100644 --- a/docs/plan/chain/account-auth-cutover.md +++ b/docs/plan/chain/account-auth-cutover.md @@ -19,7 +19,7 @@ Run via the new orchestrator `scripts/heima-cutover-account-auth.sh` (wired into | **0. Pre-flight** | Confirm the local `SidecarRegistry.sol` exposes the account-auth shape (e.g. `setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)` selector present in the ABI, NOT `setScopeWithWebauthn`). Back up the current `*_ADDRESS_*` env values to `operator-workstation.env.pre-cutover.bak`. | backup file already exists | | **1. Redeploy v2 set** | `FORCE_DEPLOY=1 bash scripts/heima-bring-up.sh` → new registry/scope/epoch/audit/verifiers; its `env_set` writes the new addresses. | a `CUTOVER_DONE_${PROFILE}=1` marker is set in env AND the live scope answers `setScope` (account-auth selector) — see Idempotency below | | **2. Redeploy P256AccountFactory** | Deploy the E5-complete `P256AccountFactory` (embeds the recover()-capable `P256Account`); `env_set P256_ACCOUNT_FACTORY_ADDRESS_${PROFILE}`. | `cast code` on the stored factory AND its embedded account has `recover()` (selector probe) | -| **3. Onboarding-as-account** | `scripts/heima-deploy-master-account.sh`: from the master's enrolled K11 pubkey, `factory.getAddress(...)` → `cast code` → `createAccount(...)` if absent; fund the EntryPoint deposit (≥ ED); then `registerFirstMasterDevice(operatorOmni, account, …)` so `operatorMasterWallet[omni] == account`. | account already deployed + `operatorMasterWallet == account` | +| **3. Onboarding-as-account** | **Reuse the existing `harness/scripts/erc4337-register-master.sh`** (`build` then `submit`) — it already does `factory.getAddress`→`cast code`→`createAccount`, funds the EntryPoint deposit, and registers the master via a passkey-account UserOp so `operatorMasterWallet[omni] == account`. Do **not** write a new helper. | its `build` already prints `{skipped:"already-registered"}` when the device exists | | **4. Re-bootstrap actors** | Re-register each agent (`heima-agent-create.sh`) + re-grant scopes (`heima-scope-set.sh`, now `setScope` path) on the new contracts. | `isActive(deviceKeyHash)` / `getScope` already matches | | **5. Code + doc updates** | `heima-scope-set.sh` `setScopeWithWebauthn`→`setScope`; `verify-heima-contracts.sh` (account-auth assertions); **arch.md §10/§12** (master = account; scope grant = `setScope`); the broker reads scope via env so no code change, just the new `SCOPE_CONTRACT_ADDRESS_*`. | files already at account-auth form (grep guard) | | **6. Broker redeploy** | `bash scripts/setup-broker-host.sh --ref main` on the broker host → picks up new registry/scope addresses + the `sponsored_accept` module + (when landed) the `/v1/accept/*` routes. | broker already on the target ref + env | @@ -35,9 +35,15 @@ A re-run with both present logs `skip already-cut-over` and exits 0. `--force-cu ## Scripts to implement (the deliverable this spec defines) +Only **one** new script is needed — every other phase reuses an existing idempotent helper: + - `scripts/heima-cutover-account-auth.sh` — the Phase 0/1/2/5 orchestrator (idempotent, `ok`/`skip`/`fail` logging, `--force-cutover`, env-namespaced, no hardcoded values). Wired into `setup-heima.sh` behind `--cutover-account-auth` (destructive ⇒ opt-in, NOT in the plain flow — the one allowed exception to "plain = prod" because a redeploy resets state). -- `scripts/heima-deploy-master-account.sh` — Phase 3 (onboarding-as-account), idempotent, mirrors `harness/erc4337-master-e8.sh`'s `factory.createAccount` + EntryPoint-deposit steps. -- Phase 4 reuses the existing `heima-agent-create.sh` / `heima-scope-set.sh` (idempotent already). +- Phase 3 reuses **`erc4337-register-master.sh`** (`build`+`submit`) — already deploys the account + funds + registers-as-account. +- Phase 4 reuses `heima-agent-create.sh` / `heima-scope-set.sh` (idempotent already). + +### Decoupling — master-as-account does NOT need the cutover + +`erc4337-register-master.sh`'s header confirms it is *viable on the live pre-cutover registry* (no EOA-only guard), so `operatorMasterWallet[omni]` can be set to the `P256Account` **today**. What still needs the cutover is the **accept batch's `setScope` (P.3)**: the live scope contract only has `setScopeWithWebauthn`, not the `msg.sender`-gated `setScope` the batch calls. So the work can stage as: (a) register the master-as-account now (Phase 3, no disruption), exercise `/v1/accept/build` against it; (b) the disruptive registry/scope redeploy (Phases 1–2) only when the account-auth `setScope` is required end-to-end. This shrinks the blast radius — the `registerAgentDevice` half of the batch may even work pre-cutover if the live registry already carries the `msg.sender == master` guard (verify with a `cast call` probe before relying on it). ## Env discipline (HARD — any new key) From 0e8648551b2b0ace57f25c013c400c5f66583e66 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 8 Jun 2026 03:16:51 +0800 Subject: [PATCH 21/85] =?UTF-8?q?feat:=20#225=20E7=20=E2=80=94=20heima-cut?= =?UTF-8?q?over-account-auth.sh=20(the=20account-auth=20cutover=20orchestr?= =?UTF-8?q?ator)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one new script the cutover spec calls for. Forces a redeploy of the v2 set so the account-auth sources (E3) go live, making the #225 accept batch's setScope (P.3) real. Idempotent + safe + bash -n clean. scripts/heima-cutover-account-auth.sh: - Phase 0: pre-flight (assert local AgentKeysScope.sol is account-auth — setScope present, setScopeWithWebauthn gone) + back up the env addresses to operator-workstation.env.pre-cutover.bak (idempotent: skip if present). - Phase 1: redeploy via FORCE_DEPLOY=1 heima-bring-up.sh, then verify + set the CUTOVER_DONE_ marker. DESTRUCTIVE → gated behind --yes; refuses otherwise. Idempotency ground truth is a read-only probe: the live scope bytecode carrying the setScope selector d8e9e3c6 (the marker is just the fast path). - Phase 2: factory CHECK only (E5 recover() isn't needed for accept; no reusable factory-deploy helper exists, so it doesn't blind-deploy). - Prints the follow-ups: re-register master-as-account (erc4337-register-master.sh), re-bootstrap agents/scopes, the repo edits (heima-scope-set.sh→setScope, arch.md), broker redeploy (setup-broker-host.sh --ref main). Classified as a directly-callable SURGICAL helper (the three-entry-points exemption for destructive heima-*-revoke/-rotate tools) — NOT wired into setup-heima.sh's plain flow, since a plain run must never reset on-chain state. Spec updated to match. Verified: bash -n clean; --help + unknown-arg guard work; setScope selector d8e9e3c6 confirmed against the earlier cast golden vectors. Cannot run e2e here (live mainnet redeploy). Refs #225. --- docs/plan/chain/account-auth-cutover.md | 4 +- scripts/heima-cutover-account-auth.sh | 171 ++++++++++++++++++++++++ 2 files changed, 173 insertions(+), 2 deletions(-) create mode 100755 scripts/heima-cutover-account-auth.sh diff --git a/docs/plan/chain/account-auth-cutover.md b/docs/plan/chain/account-auth-cutover.md index 2c88c4da..7d1635f8 100644 --- a/docs/plan/chain/account-auth-cutover.md +++ b/docs/plan/chain/account-auth-cutover.md @@ -12,7 +12,7 @@ ## Procedure (idempotent; each phase pre-checks + short-circuits) -Run via the new orchestrator `scripts/heima-cutover-account-auth.sh` (wired into `setup-heima.sh` as a NEW, explicitly-opt-in step — it is NOT part of a plain `setup-heima.sh` run because it is destructive; gate it behind `--cutover-account-auth`). All addresses are env-namespaced (`*_HEIMA` / `*_HEIMA_PASEO`) via `env_set`; nothing hardcoded. +Run via the new orchestrator `scripts/heima-cutover-account-auth.sh`. It is a **directly-callable surgical helper** — classified with the existing destructive `heima-*-revoke` / `heima-k3-rotate` helpers under the three-entry-points exemption ("tools, run on their own"), NOT wired into `setup-heima.sh`'s plain flow (a plain run must never reset on-chain state). All addresses are env-namespaced (`*_HEIMA` / `*_HEIMA_PASEO`) via `env_set`; nothing hardcoded. | Phase | Action | Idempotency check (skip when…) | |---|---|---| @@ -37,7 +37,7 @@ A re-run with both present logs `skip already-cut-over` and exits 0. `--force-cu Only **one** new script is needed — every other phase reuses an existing idempotent helper: -- `scripts/heima-cutover-account-auth.sh` — the Phase 0/1/2/5 orchestrator (idempotent, `ok`/`skip`/`fail` logging, `--force-cutover`, env-namespaced, no hardcoded values). Wired into `setup-heima.sh` behind `--cutover-account-auth` (destructive ⇒ opt-in, NOT in the plain flow — the one allowed exception to "plain = prod" because a redeploy resets state). +- `scripts/heima-cutover-account-auth.sh` ✅ **written** — the Phase 0/1/2 orchestrator (idempotent via the `CUTOVER_DONE_` marker + the live `setScope`-selector `d8e9e3c6` bytecode probe; `ok`/`skip`/`fail` logging; `--yes` gate on the destructive redeploy; `--force-cutover`; env-namespaced; no hardcoded values; `bash -n` clean). A **directly-callable surgical helper** (the three-entry-points exemption for destructive `heima-*-revoke`/`-rotate` tools) — NOT in `setup-heima.sh`'s plain flow. Phase 1 delegates to `FORCE_DEPLOY=1 heima-bring-up.sh`; Phase 2 only *checks* the factory (the E5 `recover()` redeploy is a separate manual concern, not needed for accept). Phase 5 (the `heima-scope-set.sh` `setScopeWithWebauthn`→`setScope` + arch.md edits) are repo commits, printed as follow-ups by the script, not done at runtime. - Phase 3 reuses **`erc4337-register-master.sh`** (`build`+`submit`) — already deploys the account + funds + registers-as-account. - Phase 4 reuses `heima-agent-create.sh` / `heima-scope-set.sh` (idempotent already). diff --git a/scripts/heima-cutover-account-auth.sh b/scripts/heima-cutover-account-auth.sh new file mode 100755 index 00000000..a3e6dc0a --- /dev/null +++ b/scripts/heima-cutover-account-auth.sh @@ -0,0 +1,171 @@ +#!/usr/bin/env bash +# scripts/heima-cutover-account-auth.sh — the #225 / #164 E7 account-auth cutover. +# +# Forces a redeploy of the v2 contract set (SidecarRegistry + AgentKeysScope + …) +# so the **account-auth** sources (E3: master writes gated by `msg.sender == +# operatorMasterWallet`, `setScopeWithWebauthn`→`setScope`) go LIVE — replacing the +# pre-E3 bytecode that `heima-bring-up.sh`'s `cast code` idempotency check otherwise +# keeps in place. This is what makes the #225 accept batch's `setScope` (P.3) call +# real. Full spec: docs/plan/chain/account-auth-cutover.md. +# +# ⚠️ DESTRUCTIVE: a redeploy mints NEW addresses with EMPTY state — the registered +# master, every agent binding, every scope grant, the K3 epoch counter and the +# audit history are NOT migrated. After it, the master + agents must be +# re-registered (Phase 3/4 below, separate idempotent helpers). Announce + schedule +# this; it is OPT-IN (NOT part of a plain `setup-heima.sh` run). +# +# Idempotent: a re-run with the cutover already done logs `skip already-cut-over` +# and exits 0. The ground-truth probe is the live scope's bytecode carrying the +# account-auth `setScope` selector (d8e9e3c6); the `CUTOVER_DONE_` env +# marker is the fast path. `--force-cutover` clears the marker to redeploy again. +# +# Phases here: 0 (pre-flight + env backup), 1 (redeploy v2 set), 2 (factory check). +# Phase 3 (onboarding-as-account) = `harness/scripts/erc4337-register-master.sh`; +# Phase 4 (re-bootstrap actors) = `heima-agent-create.sh` / `heima-scope-set.sh`; +# Phase 6 (broker redeploy) = `setup-broker-host.sh --ref main`. All printed at the +# end as the required follow-ups. +# +# Usage: +# bash scripts/heima-cutover-account-auth.sh [--chain heima] [--yes] [--force-cutover] +# AGENTKEYS_CHAIN, ENV_FILE, AGENTKEYS_CHAIN_RPC_HTTP honored from the environment. +set -euo pipefail + +# ─── args + env ──────────────────────────────────────────────────────────────── +CHAIN="${AGENTKEYS_CHAIN:-heima}" +ASSUME_YES=0 +FORCE_CUTOVER=0 +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +ENV_FILE="${ENV_FILE:-$REPO_ROOT/scripts/operator-workstation.env}" + +while [ $# -gt 0 ]; do + case "$1" in + --chain) CHAIN="$2"; shift 2 ;; + --chain=*) CHAIN="${1#*=}"; shift ;; + --env-file) ENV_FILE="$2"; shift 2 ;; + --env-file=*) ENV_FILE="${1#*=}"; shift ;; + --yes|-y) ASSUME_YES=1; shift ;; + --force-cutover) FORCE_CUTOVER=1; shift ;; + -h|--help) sed -n '2,40p' "$0"; exit 0 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac +done + +# ─── logging (mirrors setup-heima.sh) ──────────────────────────────────────────── +ok() { printf ' ok %s\n' "$1" >&2; } +skip() { printf ' skip %s\n' "$1" >&2; } +fail() { printf ' fail %s\n' "$1" >&2; } +step() { printf '==> %s\n' "$1" >&2; } +die() { fail "$1"; exit 1; } + +[ -f "$ENV_FILE" ] || die "env file not found: $ENV_FILE" +# shellcheck disable=SC1090 +set -a; . "$ENV_FILE"; set +a + +PROFILE_UC="$(printf '%s' "$CHAIN" | tr '[:lower:]-' '[:upper:]_')" +RPC_HTTP="${AGENTKEYS_CHAIN_RPC_HTTP:-$(eval echo "\${RPC_HTTP_${PROFILE_UC}:-}")}" +[ -n "$RPC_HTTP" ] || die "no RPC: set AGENTKEYS_CHAIN_RPC_HTTP or RPC_HTTP_${PROFILE_UC} in $ENV_FILE" + +SCOPE_ADDR="$(eval echo "\${SCOPE_CONTRACT_ADDRESS_${PROFILE_UC}:-}")" +MARKER_KEY="CUTOVER_DONE_${PROFILE_UC}" +MARKER_VAL="$(eval echo "\${${MARKER_KEY}:-}")" +SCOPE_SET_SELECTOR="d8e9e3c6" # setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32) + +for t in cast grep; do command -v "$t" >/dev/null 2>&1 || die "missing tool: $t"; done + +# Idempotent env writer (update-or-append; bash-only, no zsh modifier hazards). +env_set() { + local key="$1" val="$2" tmp + if grep -qE "^${key}=" "$ENV_FILE"; then + tmp="$(mktemp)" + sed "s|^${key}=.*|${key}=${val}|" "$ENV_FILE" >"$tmp" && mv "$tmp" "$ENV_FILE" + else + printf '%s=%s\n' "$key" "$val" >>"$ENV_FILE" + fi +} + +# Ground-truth probe: does the LIVE scope carry the account-auth setScope selector? +scope_is_account_auth() { + local addr="$1" code + [ -n "$addr" ] || return 1 + code="$(cast code "$addr" --rpc-url "$RPC_HTTP" 2>/dev/null || echo 0x)" + printf '%s' "$code" | grep -qi "$SCOPE_SET_SELECTOR" +} + +# ─── Phase 0 — pre-flight + env backup ─────────────────────────────────────────── +phase0() { + step "Phase 0 — pre-flight + backup" + local scope_src="$REPO_ROOT/crates/agentkeys-chain/src/AgentKeysScope.sol" + [ -f "$scope_src" ] || die "account-auth sources not found ($scope_src) — run from a checkout with crates/agentkeys-chain" + if grep -qE "function setScope\(" "$scope_src" && ! grep -qE "function setScopeWithWebauthn\(" "$scope_src"; then + ok "local AgentKeysScope.sol is account-auth (setScope present, setScopeWithWebauthn gone)" + else + die "local AgentKeysScope.sol is NOT account-auth — refusing to cut over to non-E3 bytecode" + fi + local bak="$ENV_FILE.pre-cutover.bak" + if [ -f "$bak" ]; then + skip "env backup already exists ($bak)" + else + cp "$ENV_FILE" "$bak" && ok "backed up env → $bak (rollback: restore it + redeploy broker)" + fi +} + +# ─── Phase 1 — force-redeploy the account-auth v2 set ──────────────────────────── +phase1() { + step "Phase 1 — redeploy v2 set (account-auth)" + if [ "$FORCE_CUTOVER" != "1" ] && [ "$MARKER_VAL" = "1" ] && scope_is_account_auth "$SCOPE_ADDR"; then + skip "already-cut-over ($MARKER_KEY=1 and live scope carries setScope) — nothing to do" + return 0 + fi + if scope_is_account_auth "$SCOPE_ADDR" && [ "$FORCE_CUTOVER" != "1" ]; then + ok "live scope already account-auth; setting $MARKER_KEY (no redeploy needed)" + env_set "$MARKER_KEY" 1 + return 0 + fi + if [ "$ASSUME_YES" != "1" ]; then + printf '\n ⚠️ DESTRUCTIVE: this redeploys registry/scope/epoch/audit to NEW addresses and\n' >&2 + printf ' RESETS all on-chain state (master, agents, scopes, audit). Re-run with --yes\n' >&2 + printf ' to proceed after you have announced + scheduled the cutover.\n\n' >&2 + die "refusing to redeploy without --yes" + fi + step " → FORCE_DEPLOY=1 heima-bring-up.sh" + FORCE_DEPLOY=1 AGENTKEYS_CHAIN="$CHAIN" ENV_FILE="$ENV_FILE" bash "$REPO_ROOT/scripts/heima-bring-up.sh" \ + || die "heima-bring-up.sh (FORCE_DEPLOY) failed" + # Re-source to pick up the freshly env_set addresses, then verify the new scope. + set -a; . "$ENV_FILE"; set +a + SCOPE_ADDR="$(eval echo "\${SCOPE_CONTRACT_ADDRESS_${PROFILE_UC}:-}")" + if scope_is_account_auth "$SCOPE_ADDR"; then + env_set "$MARKER_KEY" 1 + ok "redeploy verified — new scope $SCOPE_ADDR carries setScope; $MARKER_KEY=1" + else + die "post-redeploy probe failed — new scope $SCOPE_ADDR lacks the setScope selector" + fi +} + +# ─── Phase 2 — factory check (E5 recover() is NOT needed for accept) ───────────── +phase2() { + step "Phase 2 — P256AccountFactory check" + local factory; factory="$(eval echo "\${P256_ACCOUNT_FACTORY_ADDRESS_${PROFILE_UC}:-}")" + if [ -z "$factory" ]; then + skip "no factory address in env — the 4337 infra (E1) is deployed separately; not required for the accept batch" + return 0 + fi + local code; code="$(cast code "$factory" --rpc-url "$RPC_HTTP" 2>/dev/null || echo 0x)" + if [ "$code" != "0x" ] && [ -n "$code" ]; then + ok "factory live at $factory (accept needs no factory change; E5 recover() redeploy is a separate, manual guardian-recovery concern)" + else + skip "factory $factory has no code — redeploy is a separate E1 step, not part of this cutover" + fi +} + +phase0 +phase1 +phase2 + +step "Cutover chain steps done — required follow-ups (separate idempotent helpers):" +cat >&2 <<'NEXT' + 3. Re-register master-as-account: harness/scripts/erc4337-register-master.sh build|submit + 4. Re-bootstrap agents + scopes: heima-agent-create.sh ; heima-scope-set.sh + 5. Repo edits (commit): heima-scope-set.sh setScopeWithWebauthn→setScope ; arch.md §10/§12 + 6. Broker redeploy (broker host): bash scripts/setup-broker-host.sh --ref main +NEXT +ok "account-auth cutover orchestrator complete" From 877815ded0010d36dbb87722fadaf511c71740dd Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 8 Jun 2026 13:09:25 +0800 Subject: [PATCH 22/85] =?UTF-8?q?fix:=20#225=20=E2=80=94=20heima-cutover-a?= =?UTF-8?q?ccount-auth.sh=20resolves=20RPC=20via=20agentkeys=20chain=20sho?= =?UTF-8?q?w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script died immediately with "no RPC" because it used a made-up resolution (AGENTKEYS_CHAIN_RPC_HTTP — a broker-runtime var — plus an invented RPC_HTTP_HEIMA fallback), neither of which operator-workstation.env carries. Diagnosis: both heima-bring-up.sh:122 and setup-heima.sh:195 resolve the chain RPC the same way — `agentkeys chain show "$CHAIN" | jq -r .rpc.http` (no RPC env key exists). Switched to that; added jq + agentkeys to the tool pre-check. Verified live: it now resolves https://rpc.heima-parachain.heima.network and runs to the destructive --yes gate. Also: back up the env to $HOME/.agentkeys/.pre-cutover.bak instead of next to the git-tracked operator-workstation.env (a .bak there would surface as untracked). Verified the backup lands in ~/.agentkeys and leaves git status clean. Other assumptions re-checked against reality (correct): the SCOPE/REGISTRY/FACTORY address keys exist in operator-workstation.env; the profile suffix uses the sibling idiom tr 'a-z-' 'A-Z_'; the phase-0 guard holds (source AgentKeysScope.sol has setScope, no setScopeWithWebauthn). Refs #225. --- docs/plan/chain/account-auth-cutover.md | 4 ++-- scripts/heima-cutover-account-auth.sh | 22 +++++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/docs/plan/chain/account-auth-cutover.md b/docs/plan/chain/account-auth-cutover.md index 7d1635f8..242a6916 100644 --- a/docs/plan/chain/account-auth-cutover.md +++ b/docs/plan/chain/account-auth-cutover.md @@ -16,7 +16,7 @@ Run via the new orchestrator `scripts/heima-cutover-account-auth.sh`. It is a ** | Phase | Action | Idempotency check (skip when…) | |---|---|---| -| **0. Pre-flight** | Confirm the local `SidecarRegistry.sol` exposes the account-auth shape (e.g. `setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)` selector present in the ABI, NOT `setScopeWithWebauthn`). Back up the current `*_ADDRESS_*` env values to `operator-workstation.env.pre-cutover.bak`. | backup file already exists | +| **0. Pre-flight** | Confirm the local `SidecarRegistry.sol` exposes the account-auth shape (e.g. `setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)` selector present in the ABI, NOT `setScopeWithWebauthn`). Back up the env to `$HOME/.agentkeys/operator-workstation.env.pre-cutover.bak` (NOT next to the git-tracked env file). | backup file already exists | | **1. Redeploy v2 set** | `FORCE_DEPLOY=1 bash scripts/heima-bring-up.sh` → new registry/scope/epoch/audit/verifiers; its `env_set` writes the new addresses. | a `CUTOVER_DONE_${PROFILE}=1` marker is set in env AND the live scope answers `setScope` (account-auth selector) — see Idempotency below | | **2. Redeploy P256AccountFactory** | Deploy the E5-complete `P256AccountFactory` (embeds the recover()-capable `P256Account`); `env_set P256_ACCOUNT_FACTORY_ADDRESS_${PROFILE}`. | `cast code` on the stored factory AND its embedded account has `recover()` (selector probe) | | **3. Onboarding-as-account** | **Reuse the existing `harness/scripts/erc4337-register-master.sh`** (`build` then `submit`) — it already does `factory.getAddress`→`cast code`→`createAccount`, funds the EntryPoint deposit, and registers the master via a passkey-account UserOp so `operatorMasterWallet[omni] == account`. Do **not** write a new helper. | its `build` already prints `{skipped:"already-registered"}` when the device exists | @@ -51,7 +51,7 @@ Only **one** new script is needed — every other phase reuses an existing idemp ## Rollback -Restore `operator-workstation.env.pre-cutover.bak` over `operator-workstation.env` and redeploy the broker (`setup-broker-host.sh --ref main`). The old (pre-E3) contracts are still live at their original addresses with their original state intact, so reverting the env pointers fully restores the prior demo. (The new contracts are simply orphaned.) +Restore `$HOME/.agentkeys/operator-workstation.env.pre-cutover.bak` over `scripts/operator-workstation.env` and redeploy the broker (`setup-broker-host.sh --ref main`). The old (pre-E3) contracts are still live at their original addresses with their original state intact, so reverting the env pointers fully restores the prior demo. (The new contracts are simply orphaned.) ## arch.md sync (source-of-truth rule) diff --git a/scripts/heima-cutover-account-auth.sh b/scripts/heima-cutover-account-auth.sh index a3e6dc0a..2b1f6498 100755 --- a/scripts/heima-cutover-account-auth.sh +++ b/scripts/heima-cutover-account-auth.sh @@ -61,17 +61,22 @@ die() { fail "$1"; exit 1; } # shellcheck disable=SC1090 set -a; . "$ENV_FILE"; set +a -PROFILE_UC="$(printf '%s' "$CHAIN" | tr '[:lower:]-' '[:upper:]_')" -RPC_HTTP="${AGENTKEYS_CHAIN_RPC_HTTP:-$(eval echo "\${RPC_HTTP_${PROFILE_UC}:-}")}" -[ -n "$RPC_HTTP" ] || die "no RPC: set AGENTKEYS_CHAIN_RPC_HTTP or RPC_HTTP_${PROFILE_UC} in $ENV_FILE" +PROFILE_UC="$(printf '%s' "$CHAIN" | tr 'a-z-' 'A-Z_')" + +for t in cast grep jq agentkeys; do command -v "$t" >/dev/null 2>&1 || die "missing tool: $t"; done + +# The RPC comes from the agentkeys chain profile — the SAME resolution as +# heima-bring-up.sh:122 and setup-heima.sh:195. There is NO RPC key in +# operator-workstation.env; do not invent one. +RPC_HTTP="$(agentkeys chain show "$CHAIN" 2>/dev/null | jq -r '.rpc.http')" +[ -n "$RPC_HTTP" ] && [ "$RPC_HTTP" != "null" ] \ + || die "could not resolve RPC for chain '$CHAIN' via 'agentkeys chain show' — is the chain profile defined?" SCOPE_ADDR="$(eval echo "\${SCOPE_CONTRACT_ADDRESS_${PROFILE_UC}:-}")" MARKER_KEY="CUTOVER_DONE_${PROFILE_UC}" MARKER_VAL="$(eval echo "\${${MARKER_KEY}:-}")" SCOPE_SET_SELECTOR="d8e9e3c6" # setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32) -for t in cast grep; do command -v "$t" >/dev/null 2>&1 || die "missing tool: $t"; done - # Idempotent env writer (update-or-append; bash-only, no zsh modifier hazards). env_set() { local key="$1" val="$2" tmp @@ -101,11 +106,14 @@ phase0() { else die "local AgentKeysScope.sol is NOT account-auth — refusing to cut over to non-E3 bytecode" fi - local bak="$ENV_FILE.pre-cutover.bak" + # Back up to $HOME/.agentkeys (where the repo keeps operator state) — NOT next to + # the env file, which is git-tracked and would surface the .bak as untracked. + local bak_dir="${HOME}/.agentkeys"; mkdir -p "$bak_dir" + local bak="$bak_dir/$(basename "$ENV_FILE").pre-cutover.bak" if [ -f "$bak" ]; then skip "env backup already exists ($bak)" else - cp "$ENV_FILE" "$bak" && ok "backed up env → $bak (rollback: restore it + redeploy broker)" + cp "$ENV_FILE" "$bak" && ok "backed up env → $bak (rollback: cp it back + redeploy broker)" fi } From 0dc4365cd2f81d18b6ae5c560c2285722ce6b40c Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 8 Jun 2026 13:21:37 +0800 Subject: [PATCH 23/85] =?UTF-8?q?docs:=20#225=20=E2=80=94=20account-auth?= =?UTF-8?q?=20cutover=20operator=20runbook=20(+=20correct=20the=20post-cut?= =?UTF-8?q?over=20re-bind=20path)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds docs/operator-runbook-account-auth-cutover.md — the full 5-step operator procedure for the disruptive cutover, in the operator-runbook-*.md convention (H1, > warning blocks, ordered steps, rollback). Writing it surfaced a correctness bug in the earlier spec + the script's printed follow-ups: post-cutover, agent binding + scope grants go through ACCOUNT UserOps (the #225 accept flow), because account-auth gates registry/scope writes on msg.sender == operatorMasterWallet (the P256Account). The pre-cutover scripts do NOT work post-cutover — verified: - heima-agent-create.sh sends registerAgentDevice from the deployer EOA (≠ the account); - heima-scope-set.sh calls setScopeWithWebauthn (the assertion-in-calldata path account-auth removes; the new setScope is msg.sender-gated, no assertion param). So the runbook leads with two warnings: (1) SEQUENCING — run the cutover only AFTER the #225 accept flow is wired, else agents are stranded (you can re-register the master but not re-bind agents); (2) DESTRUCTIVE — state reset → full re-bootstrap. Corrected to match: - spec Phase 4 (re-bind = #225 accept flow, not heima-agent-create/heima-scope-set); - spec Phase 5 (drop the bogus heima-scope-set.sh setScopeWithWebauthn→setScope edit — it's a pre-cutover tool, retired post-cutover; just arch.md §10/§12 + deployed-contracts.md); - the script's printed follow-ups (point at the #225 accept flow + the new runbook). Verified: script bash -n clean; runbook H1/no-frontmatter/warnings present. Refs #225. --- docs/operator-runbook-account-auth-cutover.md | 106 ++++++++++++++++++ docs/plan/chain/account-auth-cutover.md | 6 +- scripts/heima-cutover-account-auth.sh | 13 ++- 3 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 docs/operator-runbook-account-auth-cutover.md diff --git a/docs/operator-runbook-account-auth-cutover.md b/docs/operator-runbook-account-auth-cutover.md new file mode 100644 index 00000000..92f8a0a3 --- /dev/null +++ b/docs/operator-runbook-account-auth-cutover.md @@ -0,0 +1,106 @@ +# Operator runbook — account-auth cutover (#225 / #164 E7) + +The full, ordered procedure to cut the live Heima contracts over to the **account-auth** +set — where `SidecarRegistry` / `AgentKeysScope` master writes are gated by +`msg.sender == operatorMasterWallet` (the operator's ERC-4337 `P256Account`), and +`AgentKeysScope` exposes `setScope` (not `setScopeWithWebauthn`). This is what makes the +#225 agent-accept batch's `setScope` (P.3) call real. Design + idempotency rationale: +[`plan/chain/account-auth-cutover.md`](plan/chain/account-auth-cutover.md). + +> **⚠️ SEQUENCING — run this AFTER the #225 accept flow is wired + deployed.** Post-cutover, +> agent binding + scope grants go through **account UserOps** (the #225 accept flow). The +> pre-cutover scripts **stop working**: `heima-agent-create.sh` sends `registerAgentDevice` +> from the deployer EOA (fails once `operatorMasterWallet` is the account), and +> `heima-scope-set.sh` calls `setScopeWithWebauthn` (the assertion-in-calldata path that +> account-auth removes). If you cut over *before* `/v1/accept/*` is live you can re-register +> the master but **cannot re-bind agents** — they are stranded until the accept flow ships. + +> **⚠️ DESTRUCTIVE.** A redeploy mints NEW addresses with EMPTY state — the registered +> master, every agent binding, every scope grant, the K3 epoch counter and the audit +> history are **NOT migrated**. Everything must be re-bootstrapped (steps 3 + 5). The +> current demo breaks until then. **Announce + schedule it; never run it mid-demo.** CI +> skips first-master, so CI is unaffected. Rollback is at the bottom. + +## What you need + +- **Operator laptop**, AWS profile `agentkeys-admin`, the funded Heima deployer key, the + agentkeys CLI + `cast`/`forge`/`jq`, and your **enrolled K11 passkey** (for the master + re-register Touch ID in step 3). +- **SSH to the broker host** (`ssh-agentkeys`) for the broker redeploy (step 4). +- The **#225 accept flow live** (broker `/v1/accept/{build,submit}` + daemon wiring) — per + the sequencing note above. + +## Steps (operator laptop unless noted) + +**1. Redeploy the account-auth contracts.** Idempotent (a `CUTOVER_DONE_` marker + +a read-only `setScope`-selector bytecode probe); backs the env up to `~/.agentkeys` first; +`--yes` is required because it is destructive. + +```bash +bash scripts/heima-cutover-account-auth.sh --yes +``` + +On success: new `SCOPE_CONTRACT_ADDRESS_HEIMA` / `SIDECAR_REGISTRY_ADDRESS_HEIMA` / epoch / +audit in `scripts/operator-workstation.env`, and `CUTOVER_DONE_HEIMA=1`. + +**2. Verify the redeploy** (read-only, zero gas): + +```bash +AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh +``` + +**3. Re-register your master as a `P256Account`** (interactive — **Touch ID**): + +```bash +bash harness/scripts/erc4337-register-master.sh build \ + --operator-omni 0x --pubx --puby \ + --cred-id-hash 0x<…> --rpid-hash 0x<…> --state-file /tmp/master-userop.json +# K11 (browser/hardware) signs the printed userop_hash, then: +bash harness/scripts/erc4337-register-master.sh submit \ + --state-file /tmp/master-userop.json --cred-id-hash 0x<…> \ + --authdata 0x<…> --clientdata 0x<…> --challenge-loc --r --s +``` + +Verify: `cast call "$SIDECAR_REGISTRY_ADDRESS_HEIMA" "operatorMasterWallet(bytes32)(address)" 0x --rpc-url "$(agentkeys chain show heima | jq -r .rpc.http)"` returns your **account** address (not the deployer EOA). + +**4. Redeploy the broker** (on the **broker host**) so it reads the new registry/scope +addresses and serves the `/v1/accept/*` routes: + +```bash +ssh-agentkeys # interactive shell on the broker host +bash scripts/setup-broker-host.sh --ref main # idempotent +``` + +**5. Re-bind agents + grant scopes — via the #225 accept flow** (NOT the old scripts). For +each agent, run the §10.2 pairing (`agentkeys-daemon --request-pairing` → master claim → +**accept**); the accept now submits the `executeBatch([registerAgentDevice, setScope])` +account UserOp, which is the only path the account-auth contracts accept. +`heima-agent-create.sh` / `heima-scope-set.sh` are **pre-cutover only** and will revert here. + +**6. Verify an agent** — its granted `memory:` namespaces show in the permission view +(or `cast call "$SCOPE" "getScope(bytes32,bytes32)" …`). + +**7. Land the doc updates (repo commit):** `arch.md` §10/§12 (master = account; agent +binding + scope grant ride the account UserOp), [`spec/deployed-contracts.md`](spec/deployed-contracts.md) +(the new addresses), and flip the `plan/chain/erc4337-master-account.md` §3.1 cutover note ⏭️→✅. + +## Rollback + +The old (pre-E3) contracts are still live at their original addresses with their state +intact — reverting the env pointers fully restores the prior setup (the new contracts are +simply orphaned): + +```bash +cp ~/.agentkeys/operator-workstation.env.pre-cutover.bak scripts/operator-workstation.env +ssh-agentkeys; bash scripts/setup-broker-host.sh --ref main # broker re-reads the old addresses +``` + +## Why not just run one script? + +A single headless cutover command isn't achievable: step 3 (master re-register) is an +interactive Touch ID ceremony, step 5 re-binds agents through the #225 accept flow, and +step 4 runs on a different machine. `heima-cutover-account-auth.sh` automates the destructive +*contract redeploy* (steps 1–2); this runbook is the surrounding sequence. And per the +spec's **decoupling** finding, if you only need the master-as-account + the +`registerAgentDevice` half, you can skip the cutover entirely — register the master on +today's contracts and exercise `/v1/accept/build` against it, with no state reset. diff --git a/docs/plan/chain/account-auth-cutover.md b/docs/plan/chain/account-auth-cutover.md index 242a6916..2c9b4eb3 100644 --- a/docs/plan/chain/account-auth-cutover.md +++ b/docs/plan/chain/account-auth-cutover.md @@ -20,8 +20,8 @@ Run via the new orchestrator `scripts/heima-cutover-account-auth.sh`. It is a ** | **1. Redeploy v2 set** | `FORCE_DEPLOY=1 bash scripts/heima-bring-up.sh` → new registry/scope/epoch/audit/verifiers; its `env_set` writes the new addresses. | a `CUTOVER_DONE_${PROFILE}=1` marker is set in env AND the live scope answers `setScope` (account-auth selector) — see Idempotency below | | **2. Redeploy P256AccountFactory** | Deploy the E5-complete `P256AccountFactory` (embeds the recover()-capable `P256Account`); `env_set P256_ACCOUNT_FACTORY_ADDRESS_${PROFILE}`. | `cast code` on the stored factory AND its embedded account has `recover()` (selector probe) | | **3. Onboarding-as-account** | **Reuse the existing `harness/scripts/erc4337-register-master.sh`** (`build` then `submit`) — it already does `factory.getAddress`→`cast code`→`createAccount`, funds the EntryPoint deposit, and registers the master via a passkey-account UserOp so `operatorMasterWallet[omni] == account`. Do **not** write a new helper. | its `build` already prints `{skipped:"already-registered"}` when the device exists | -| **4. Re-bootstrap actors** | Re-register each agent (`heima-agent-create.sh`) + re-grant scopes (`heima-scope-set.sh`, now `setScope` path) on the new contracts. | `isActive(deviceKeyHash)` / `getScope` already matches | -| **5. Code + doc updates** | `heima-scope-set.sh` `setScopeWithWebauthn`→`setScope`; `verify-heima-contracts.sh` (account-auth assertions); **arch.md §10/§12** (master = account; scope grant = `setScope`); the broker reads scope via env so no code change, just the new `SCOPE_CONTRACT_ADDRESS_*`. | files already at account-auth form (grep guard) | +| **4. Re-bind agents + scopes** | Via the **#225 accept flow** — the `executeBatch([registerAgentDevice, setScope])` account UserOp. **The pre-cutover `heima-agent-create.sh` (deployer-EOA `cast send`) / `heima-scope-set.sh` (`setScopeWithWebauthn`, assertion-in-calldata) do NOT work post-cutover** — account-auth requires `msg.sender == account`, so binding rides the account UserOp, not a direct cast. | agent `isActive` / `getScope` already matches | +| **5. Doc updates** | **arch.md §10/§12** (master = account; agent binding + scope grant ride the account UserOp); [`spec/deployed-contracts.md`](../../spec/deployed-contracts.md) (new addresses); flip `erc4337-master-account.md` §3.1 ⏭️→✅. No `heima-scope-set.sh` edit (it is a pre-cutover tool, retired post-cutover). `verify-heima-contracts.sh` reads addresses from env → no edit. | docs already reflect account-auth | | **6. Broker redeploy** | `bash scripts/setup-broker-host.sh --ref main` on the broker host → picks up new registry/scope addresses + the `sponsored_accept` module + (when landed) the `/v1/accept/*` routes. | broker already on the target ref + env | ## Idempotency strategy (a redeploy is NOT a first-deploy) @@ -37,7 +37,7 @@ A re-run with both present logs `skip already-cut-over` and exits 0. `--force-cu Only **one** new script is needed — every other phase reuses an existing idempotent helper: -- `scripts/heima-cutover-account-auth.sh` ✅ **written** — the Phase 0/1/2 orchestrator (idempotent via the `CUTOVER_DONE_` marker + the live `setScope`-selector `d8e9e3c6` bytecode probe; `ok`/`skip`/`fail` logging; `--yes` gate on the destructive redeploy; `--force-cutover`; env-namespaced; no hardcoded values; `bash -n` clean). A **directly-callable surgical helper** (the three-entry-points exemption for destructive `heima-*-revoke`/`-rotate` tools) — NOT in `setup-heima.sh`'s plain flow. Phase 1 delegates to `FORCE_DEPLOY=1 heima-bring-up.sh`; Phase 2 only *checks* the factory (the E5 `recover()` redeploy is a separate manual concern, not needed for accept). Phase 5 (the `heima-scope-set.sh` `setScopeWithWebauthn`→`setScope` + arch.md edits) are repo commits, printed as follow-ups by the script, not done at runtime. +- `scripts/heima-cutover-account-auth.sh` ✅ **written** — the Phase 0/1/2 orchestrator (idempotent via the `CUTOVER_DONE_` marker + the live `setScope`-selector `d8e9e3c6` bytecode probe; `ok`/`skip`/`fail` logging; `--yes` gate on the destructive redeploy; `--force-cutover`; env-namespaced; no hardcoded values; `bash -n` clean). A **directly-callable surgical helper** (the three-entry-points exemption for destructive `heima-*-revoke`/`-rotate` tools) — NOT in `setup-heima.sh`'s plain flow. Phase 1 delegates to `FORCE_DEPLOY=1 heima-bring-up.sh`; Phase 2 only *checks* the factory (the E5 `recover()` redeploy is a separate manual concern, not needed for accept). Phases 3-6 are operator steps the script prints as follow-ups, not done at runtime. **The full operator procedure is [`operator-runbook-account-auth-cutover.md`](../../operator-runbook-account-auth-cutover.md)** (this doc is the design rationale; the runbook is the how-to). - Phase 3 reuses **`erc4337-register-master.sh`** (`build`+`submit`) — already deploys the account + funds + registers-as-account. - Phase 4 reuses `heima-agent-create.sh` / `heima-scope-set.sh` (idempotent already). diff --git a/scripts/heima-cutover-account-auth.sh b/scripts/heima-cutover-account-auth.sh index 2b1f6498..4896b253 100755 --- a/scripts/heima-cutover-account-auth.sh +++ b/scripts/heima-cutover-account-auth.sh @@ -169,11 +169,14 @@ phase0 phase1 phase2 -step "Cutover chain steps done — required follow-ups (separate idempotent helpers):" +step "Contract redeploy done (steps 1-2). Required follow-ups — full runbook:" cat >&2 <<'NEXT' - 3. Re-register master-as-account: harness/scripts/erc4337-register-master.sh build|submit - 4. Re-bootstrap agents + scopes: heima-agent-create.sh ; heima-scope-set.sh - 5. Repo edits (commit): heima-scope-set.sh setScopeWithWebauthn→setScope ; arch.md §10/§12 - 6. Broker redeploy (broker host): bash scripts/setup-broker-host.sh --ref main + docs/operator-runbook-account-auth-cutover.md + 2. Verify: AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh + 3. Re-register master: harness/scripts/erc4337-register-master.sh build|submit (Touch ID) + 4. Broker redeploy (host): bash scripts/setup-broker-host.sh --ref main + 5. Re-bind agents+scopes: via the #225 accept flow (account UserOps). NOTE: the pre-cutover + heima-agent-create.sh / heima-scope-set.sh do NOT work post-cutover. + 6. Repo edits (commit): arch.md §10/§12 ; spec/deployed-contracts.md (new addresses) NEXT ok "account-auth cutover orchestrator complete" From 07bcb59cfcab67fbd9becb87aa568dd0ffc218f1 Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 8 Jun 2026 13:37:19 +0800 Subject: [PATCH 24/85] =?UTF-8?q?docs:=20#225=20=E2=80=94=20simplify=20cut?= =?UTF-8?q?over=20runbook=20(dev-only:=20cutover=20=3D=201-3,=20then=20re-?= =?UTF-8?q?onboard)=20+=20fix=20step-4=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the "no user, only developer, register again" reality: - Reframe: nothing to migrate. The cutover proper is redeploy + verify + broker redeploy (steps 1-3); registering the master + pairing agents (4-5) is just normal onboarding on the fresh contracts, not a special re-bootstrap. Dropped the "DESTRUCTIVE / announce + schedule / state NOT migrated" alarm. - Master register is still REQUIRED (the new registry is empty → registerAgentDevice would revert OperatorNotRegistered), but it's one command, not the placeholder build/submit dance: bash harness/scripts/erc4337-register-master.sh --operator-omni 0x # auto Touch ID The old step-3 block was not executable (raw 0x<…>/ placeholders + a hand-wavy "K11 signs the userop_hash"). The default `register` mode auto-runs k11 webauthn-keygen + webauthn-userop-sign; the build/submit two-phase split is only for the browser web-flow. - Synced the script's printed follow-ups + the spec Phase 3 to the one-command form. Refs #225. --- docs/operator-runbook-account-auth-cutover.md | 124 +++++++++--------- docs/plan/chain/account-auth-cutover.md | 2 +- scripts/heima-cutover-account-auth.sh | 14 +- 3 files changed, 67 insertions(+), 73 deletions(-) diff --git a/docs/operator-runbook-account-auth-cutover.md b/docs/operator-runbook-account-auth-cutover.md index 92f8a0a3..d50dabc0 100644 --- a/docs/operator-runbook-account-auth-cutover.md +++ b/docs/operator-runbook-account-auth-cutover.md @@ -1,47 +1,41 @@ # Operator runbook — account-auth cutover (#225 / #164 E7) -The full, ordered procedure to cut the live Heima contracts over to the **account-auth** -set — where `SidecarRegistry` / `AgentKeysScope` master writes are gated by -`msg.sender == operatorMasterWallet` (the operator's ERC-4337 `P256Account`), and -`AgentKeysScope` exposes `setScope` (not `setScopeWithWebauthn`). This is what makes the -#225 agent-accept batch's `setScope` (P.3) call real. Design + idempotency rationale: -[`plan/chain/account-auth-cutover.md`](plan/chain/account-auth-cutover.md). - -> **⚠️ SEQUENCING — run this AFTER the #225 accept flow is wired + deployed.** Post-cutover, -> agent binding + scope grants go through **account UserOps** (the #225 accept flow). The -> pre-cutover scripts **stop working**: `heima-agent-create.sh` sends `registerAgentDevice` -> from the deployer EOA (fails once `operatorMasterWallet` is the account), and -> `heima-scope-set.sh` calls `setScopeWithWebauthn` (the assertion-in-calldata path that -> account-auth removes). If you cut over *before* `/v1/accept/*` is live you can re-register -> the master but **cannot re-bind agents** — they are stranded until the accept flow ships. - -> **⚠️ DESTRUCTIVE.** A redeploy mints NEW addresses with EMPTY state — the registered -> master, every agent binding, every scope grant, the K3 epoch counter and the audit -> history are **NOT migrated**. Everything must be re-bootstrapped (steps 3 + 5). The -> current demo breaks until then. **Announce + schedule it; never run it mid-demo.** CI -> skips first-master, so CI is unaffected. Rollback is at the bottom. +Cut the live Heima contracts over to the **account-auth** set — where `SidecarRegistry` / +`AgentKeysScope` master writes are gated by `msg.sender == operatorMasterWallet` (the +operator's ERC-4337 `P256Account`), and `AgentKeysScope` exposes `setScope` (not +`setScopeWithWebauthn`). This is what makes the #225 agent-accept batch's `setScope` (P.3) +call real. Design + idempotency rationale: [`plan/chain/account-auth-cutover.md`](plan/chain/account-auth-cutover.md). + +> **Dev-only — nothing to migrate.** The redeploy gives fresh, empty contracts at new +> addresses. There is no production state to preserve: you simply **re-onboard** (register +> the master + pair agents) on the new contracts afterward, exactly as a fresh setup. So the +> cutover proper is just **redeploy + broker redeploy** (steps 1–3); the rest (steps 4–5) is +> normal onboarding. + +> **Sequencing.** Post-cutover, agent binding + scope grants go through **account UserOps** — +> the #225 accept flow. The pre-cutover scripts (`heima-agent-create.sh`, `heima-scope-set.sh`) +> no longer work (account-auth requires `msg.sender == account`). So you can register the +> master right after the cutover, but **pairing agents (step 5) needs `/v1/accept/*` live**. +> If the accept flow isn't wired yet, do steps 1–4 now and step 5 once it ships. ## What you need - **Operator laptop**, AWS profile `agentkeys-admin`, the funded Heima deployer key, the - agentkeys CLI + `cast`/`forge`/`jq`, and your **enrolled K11 passkey** (for the master - re-register Touch ID in step 3). -- **SSH to the broker host** (`ssh-agentkeys`) for the broker redeploy (step 4). -- The **#225 accept flow live** (broker `/v1/accept/{build,submit}` + daemon wiring) — per - the sequencing note above. + agentkeys CLI + `cast`/`forge`/`jq`, and your **K11 passkey** (Touch ID, for step 4). +- **SSH to the broker host** (`ssh-agentkeys`) for the broker redeploy (step 3). -## Steps (operator laptop unless noted) +## A. Cut over the infrastructure (the cutover proper) -**1. Redeploy the account-auth contracts.** Idempotent (a `CUTOVER_DONE_` marker + -a read-only `setScope`-selector bytecode probe); backs the env up to `~/.agentkeys` first; -`--yes` is required because it is destructive. +**1. Redeploy the account-auth contracts** — idempotent (`CUTOVER_DONE_` marker + a +read-only `setScope`-selector probe); backs the env up to `~/.agentkeys` first; `--yes` +confirms the redeploy: ```bash bash scripts/heima-cutover-account-auth.sh --yes ``` -On success: new `SCOPE_CONTRACT_ADDRESS_HEIMA` / `SIDECAR_REGISTRY_ADDRESS_HEIMA` / epoch / -audit in `scripts/operator-workstation.env`, and `CUTOVER_DONE_HEIMA=1`. +New `SCOPE_CONTRACT_ADDRESS_HEIMA` / `SIDECAR_REGISTRY_ADDRESS_HEIMA` / epoch / audit land in +`scripts/operator-workstation.env`, with `CUTOVER_DONE_HEIMA=1`. **2. Verify the redeploy** (read-only, zero gas): @@ -49,58 +43,58 @@ audit in `scripts/operator-workstation.env`, and `CUTOVER_DONE_HEIMA=1`. AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh ``` -**3. Re-register your master as a `P256Account`** (interactive — **Touch ID**): +**3. Redeploy the broker** (on the **broker host**) so it reads the new addresses + serves +`/v1/accept/*`: ```bash -bash harness/scripts/erc4337-register-master.sh build \ - --operator-omni 0x --pubx --puby \ - --cred-id-hash 0x<…> --rpid-hash 0x<…> --state-file /tmp/master-userop.json -# K11 (browser/hardware) signs the printed userop_hash, then: -bash harness/scripts/erc4337-register-master.sh submit \ - --state-file /tmp/master-userop.json --cred-id-hash 0x<…> \ - --authdata 0x<…> --clientdata 0x<…> --challenge-loc --r --s +ssh-agentkeys # shell on the broker host +bash scripts/setup-broker-host.sh --ref main # idempotent ``` -Verify: `cast call "$SIDECAR_REGISTRY_ADDRESS_HEIMA" "operatorMasterWallet(bytes32)(address)" 0x --rpc-url "$(agentkeys chain show heima | jq -r .rpc.http)"` returns your **account** address (not the deployer EOA). +## B. Re-onboard on the fresh contracts (normal onboarding, not a migration) + +The new registry is empty (`operatorMasterWallet == 0`), so the accept flow's +`registerAgentDevice` would revert (`OperatorNotRegistered`). Register the master + pair +agents the same way you would a fresh setup. -**4. Redeploy the broker** (on the **broker host**) so it reads the new registry/scope -addresses and serves the `/v1/accept/*` routes: +**4. Register the master as a `P256Account`** — one command, one **Touch ID** (it auto +enrolls/loads the K11 and signs; no manual pubkey/assertion hex): ```bash -ssh-agentkeys # interactive shell on the broker host -bash scripts/setup-broker-host.sh --ref main # idempotent +bash harness/scripts/erc4337-register-master.sh --operator-omni 0x +# default signer = hardware (Touch ID). Headless/CI: --signer software --key-file . +# = your master operator_omni (the value onboarding uses). If you don't have it +# handy, the full harness ceremony computes it for you and registers in one go: +# bash harness/v2-demo.sh # onboarding ceremony (master register + K11 + scope) ``` -**5. Re-bind agents + grant scopes — via the #225 accept flow** (NOT the old scripts). For -each agent, run the §10.2 pairing (`agentkeys-daemon --request-pairing` → master claim → -**accept**); the accept now submits the `executeBatch([registerAgentDevice, setScope])` -account UserOp, which is the only path the account-auth contracts accept. -`heima-agent-create.sh` / `heima-scope-set.sh` are **pre-cutover only** and will revert here. +Verify: `cast call "$SIDECAR_REGISTRY_ADDRESS_HEIMA" "operatorMasterWallet(bytes32)(address)" 0x --rpc-url "$(agentkeys chain show heima | jq -r .rpc.http)"` returns your **account** address (not the deployer EOA). + +**5. Pair agents — via the §10.2 accept flow** (needs `/v1/accept/*` live; see Sequencing). +For each agent: `agentkeys-daemon --request-pairing` → master claim → **accept**, which +submits the `executeBatch([registerAgentDevice, setScope])` account UserOp. +(`heima-agent-create.sh` / `heima-scope-set.sh` are pre-cutover only and revert here.) -**6. Verify an agent** — its granted `memory:` namespaces show in the permission view -(or `cast call "$SCOPE" "getScope(bytes32,bytes32)" …`). +## C. Finalize -**7. Land the doc updates (repo commit):** `arch.md` §10/§12 (master = account; agent -binding + scope grant ride the account UserOp), [`spec/deployed-contracts.md`](spec/deployed-contracts.md) -(the new addresses), and flip the `plan/chain/erc4337-master-account.md` §3.1 cutover note ⏭️→✅. +**6. Doc updates (repo commit):** `arch.md` §10/§12 (master = account; binding + scope ride +the account UserOp), [`spec/deployed-contracts.md`](spec/deployed-contracts.md) (new +addresses), flip `plan/chain/erc4337-master-account.md` §3.1 ⏭️→✅. ## Rollback -The old (pre-E3) contracts are still live at their original addresses with their state -intact — reverting the env pointers fully restores the prior setup (the new contracts are -simply orphaned): +The old (pre-E3) contracts are still live at their original addresses with state intact — +reverting the env pointers fully restores the prior setup (new contracts orphaned): ```bash cp ~/.agentkeys/operator-workstation.env.pre-cutover.bak scripts/operator-workstation.env ssh-agentkeys; bash scripts/setup-broker-host.sh --ref main # broker re-reads the old addresses ``` -## Why not just run one script? +## Why it isn't one command -A single headless cutover command isn't achievable: step 3 (master re-register) is an -interactive Touch ID ceremony, step 5 re-binds agents through the #225 accept flow, and -step 4 runs on a different machine. `heima-cutover-account-auth.sh` automates the destructive -*contract redeploy* (steps 1–2); this runbook is the surrounding sequence. And per the -spec's **decoupling** finding, if you only need the master-as-account + the -`registerAgentDevice` half, you can skip the cutover entirely — register the master on -today's contracts and exercise `/v1/accept/build` against it, with no state reset. +`heima-cutover-account-auth.sh` automates the contract redeploy (steps 1–2). The rest can't +be one headless script: the broker redeploy runs on another machine (step 3) and the master +register is an interactive Touch ID (step 4). Per the spec's **decoupling**, if you only need +the master-as-account + the `registerAgentDevice` half, you can skip the cutover entirely and +register the master on today's contracts. diff --git a/docs/plan/chain/account-auth-cutover.md b/docs/plan/chain/account-auth-cutover.md index 2c9b4eb3..15d05cc7 100644 --- a/docs/plan/chain/account-auth-cutover.md +++ b/docs/plan/chain/account-auth-cutover.md @@ -19,7 +19,7 @@ Run via the new orchestrator `scripts/heima-cutover-account-auth.sh`. It is a ** | **0. Pre-flight** | Confirm the local `SidecarRegistry.sol` exposes the account-auth shape (e.g. `setScope(bytes32,bytes32,bytes32[],bool,uint128,uint128,uint128,uint32)` selector present in the ABI, NOT `setScopeWithWebauthn`). Back up the env to `$HOME/.agentkeys/operator-workstation.env.pre-cutover.bak` (NOT next to the git-tracked env file). | backup file already exists | | **1. Redeploy v2 set** | `FORCE_DEPLOY=1 bash scripts/heima-bring-up.sh` → new registry/scope/epoch/audit/verifiers; its `env_set` writes the new addresses. | a `CUTOVER_DONE_${PROFILE}=1` marker is set in env AND the live scope answers `setScope` (account-auth selector) — see Idempotency below | | **2. Redeploy P256AccountFactory** | Deploy the E5-complete `P256AccountFactory` (embeds the recover()-capable `P256Account`); `env_set P256_ACCOUNT_FACTORY_ADDRESS_${PROFILE}`. | `cast code` on the stored factory AND its embedded account has `recover()` (selector probe) | -| **3. Onboarding-as-account** | **Reuse the existing `harness/scripts/erc4337-register-master.sh`** (`build` then `submit`) — it already does `factory.getAddress`→`cast code`→`createAccount`, funds the EntryPoint deposit, and registers the master via a passkey-account UserOp so `operatorMasterWallet[omni] == account`. Do **not** write a new helper. | its `build` already prints `{skipped:"already-registered"}` when the device exists | +| **3. Onboarding-as-account** | **Reuse the existing `harness/scripts/erc4337-register-master.sh`** in its default `register` mode — one command, `--operator-omni 0x`, auto Touch ID (it runs `k11 webauthn-keygen` + `webauthn-userop-sign` internally; the `build`/`submit` split is only the browser web-flow). It does `factory.getAddress`→`cast code`→`createAccount`, funds the EntryPoint deposit, and registers the master via a passkey-account UserOp so `operatorMasterWallet[omni] == account`. Do **not** write a new helper. | prints `{skipped:"already-registered"}` when the device exists | | **4. Re-bind agents + scopes** | Via the **#225 accept flow** — the `executeBatch([registerAgentDevice, setScope])` account UserOp. **The pre-cutover `heima-agent-create.sh` (deployer-EOA `cast send`) / `heima-scope-set.sh` (`setScopeWithWebauthn`, assertion-in-calldata) do NOT work post-cutover** — account-auth requires `msg.sender == account`, so binding rides the account UserOp, not a direct cast. | agent `isActive` / `getScope` already matches | | **5. Doc updates** | **arch.md §10/§12** (master = account; agent binding + scope grant ride the account UserOp); [`spec/deployed-contracts.md`](../../spec/deployed-contracts.md) (new addresses); flip `erc4337-master-account.md` §3.1 ⏭️→✅. No `heima-scope-set.sh` edit (it is a pre-cutover tool, retired post-cutover). `verify-heima-contracts.sh` reads addresses from env → no edit. | docs already reflect account-auth | | **6. Broker redeploy** | `bash scripts/setup-broker-host.sh --ref main` on the broker host → picks up new registry/scope addresses + the `sponsored_accept` module + (when landed) the `/v1/accept/*` routes. | broker already on the target ref + env | diff --git a/scripts/heima-cutover-account-auth.sh b/scripts/heima-cutover-account-auth.sh index 4896b253..1bd2e0fe 100755 --- a/scripts/heima-cutover-account-auth.sh +++ b/scripts/heima-cutover-account-auth.sh @@ -169,14 +169,14 @@ phase0 phase1 phase2 -step "Contract redeploy done (steps 1-2). Required follow-ups — full runbook:" +step "Contract redeploy done. Remaining steps — full runbook:" cat >&2 <<'NEXT' docs/operator-runbook-account-auth-cutover.md - 2. Verify: AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh - 3. Re-register master: harness/scripts/erc4337-register-master.sh build|submit (Touch ID) - 4. Broker redeploy (host): bash scripts/setup-broker-host.sh --ref main - 5. Re-bind agents+scopes: via the #225 accept flow (account UserOps). NOTE: the pre-cutover - heima-agent-create.sh / heima-scope-set.sh do NOT work post-cutover. - 6. Repo edits (commit): arch.md §10/§12 ; spec/deployed-contracts.md (new addresses) + 2. Verify: AGENTKEYS_CHAIN=heima bash scripts/verify-heima-contracts.sh + 3. Broker redeploy: bash scripts/setup-broker-host.sh --ref main (on the broker host) + --- then re-onboard on the fresh contracts (dev-only, nothing to migrate) --- + 4. Register master: harness/scripts/erc4337-register-master.sh --operator-omni 0x (Touch ID) + 5. Pair agents: §10.2 accept flow (account UserOps; the pre-cutover heima-agent-create / heima-scope-set do NOT work) + 6. Doc commit: arch.md §10/§12 ; spec/deployed-contracts.md (new addresses) NEXT ok "account-auth cutover orchestrator complete" From e34aee7870086b9d3c4f916678b7127312e67f3d Mon Sep 17 00:00:00 2001 From: Hanwen Cheng Date: Mon, 8 Jun 2026 14:55:22 +0800 Subject: [PATCH 25/85] feat: unpair button + ON-CHAIN device revoke (was local-only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "accept pairing" had no unpair, and the revoke that did exist (actor-detail view) only flipped LOCAL daemon state — the device stayed registered on chain. Both gaps closed: Daemon (ui_bridge.rs): - revoke_device now shells out to heima-device-revoke.sh (--agent