feat: #109 two-tier audit — real-time SSE feed + autonomous 2-min tier-A on-chain anchor#281
Open
hanwencheng wants to merge 18 commits into
Open
feat: #109 two-tier audit — real-time SSE feed + autonomous 2-min tier-A on-chain anchor#281hanwencheng wants to merge 18 commits into
hanwencheng wants to merge 18 commits into
Conversation
…nt pulls the vaulted LLM key via its granted scope The #216 gap: cred-fetch-demo/cred-wire-demo prove the chain master-self (operator==actor skips the scope check, #195) and nothing fetched from INSIDE the sandbox as the agent. This closes it across three layers: - sandbox-agent-isolation.sh: + the #216 cred half — positive (agentkeys cred fetch of the P.3-granted service, in-sandbox identity from ~/.agentkeys/harness-env) and the scope-denial negative (an un-granted probe MUST fail service_not_in_scope; any other outcome fails loud). - phase1-wire-demo.sh: 1.4b stages ~/.agentkeys/harness-env (0600) in the sandbox — also fixes the bare-shell env contract the isolation script silently lacked (it pointed at the stale :8088 MCP default); 1.4c uploads the proof script (absolute path + verified — the bare filename upload was rejected by the aiosandbox file API and curl exited 0 anyway); Phase 4.0 now fetches + plants the LLM key IN-SANDBOX as the agent (plaintext never leaves the sandbox; host-CLI fetch is the compat fallback, operator env stays the labelled DEV-only fallback). - v2-stage3-demo.sh step 18: the #216 cred-side scope triad on the granted agent — cred-fetch cap for the granted service (200), un-granted probe (ServiceNotInScope), and the CI/mock-only live REVOKE transition (setScope drops the service → the same mint is denied → restore), enforcing the '#216 revoke cuts the agent off' acceptance in CI. Also fixes upload_sandbox_isolation_test, which silently no-op'd (relative upload path + unchecked API body). Verified live: prod broker /v1/cap/cred-fetch layered errors; the 1.4b staging command (0600, 11 keys), the new script + the exact Phase 4.0 one-liner against a real aiosandbox; the fixed stage-3 upload. The chain-gated positives run in CI (software master + mock agent) and on the operator's next v2-demo.sh run (Touch ID register + pairing). Docs synced in the same change: harness/CLAUDE.md (inventory rows, sandbox role) + docs/operator-runbook-harness.md (On Sandbox, phase 3/5 proofs, two new Q&A entries).
…e aiosandbox side on the runner CI used to set --wire none, so the entire post-wire agent runtime (the MCP server + agentkeys cred fetch + the hook path) ran nowhere headless — stage-3 steps 11-12 cover raw worker curls, not the runtime the sandbox agent actually uses. - NEW harness/mock-wire-demo.sh (phase 5 under --ci / --wire mock): ensure the sanctioned mock agent + the canonical scope grant → mint operator + agent sessions headless (wallet_sig SIWE) → boot the REAL agentkeys-mcp-server on localhost (http backend + per-actor STS relay, the phase1 1.4 shape) → master-self vault a probe cred under the DEDICATED mock-wire-llm service (never openrouter — can't clobber a real vault entry) → run THE SAME sandbox-agent-isolation.sh the sandbox runs, with EXPECTED_CRED_SHA256: memory roundtrip through the MCP + the #216 authorized cred fetch (sha-exact) + the un-granted scope denial. Key custody stays operator-only by design. - v2-demo.sh: --wire gains 'mock' (CI default; 'none' stays the explicit off), WIRE_RESULT=mocked in the summary. - v2-stage3-demo.sh: ONE canonical mock-agent grant MOCK_SCOPE_SERVICES (openrouter + memory:ci-wire-proof + mock-wire-llm), used by ensure_mock_agent AND the step-18 revoke restore — phases 3 and 5 no longer flip-flop setScope every CI run (set-replace semantics). - _lib.sh: + wallet_sig_mint_jwt (the shared headless SIWE session primitive; temp-file based — jq chokes on multi-line SIWE in vars). - harness-ci.yml: comments/step name now say phases 1-6 with phase 5 mocked (the workflow already passes --ci; behavior switches with the new default). The build step already builds agentkeys-mcp-server. Verified locally: bash -n all, YAML parse, fixture gate green, the MCP server boots with the exact arg shape (healthz ok), and 'v2-demo --stage 5 --wire mock' dispatches preflight → mock-wire-demo → fails LOUD at the no-master chain gate (this laptop's registry has no master — correct; CI's software master is registered). Docs synced: harness/CLAUDE.md (rule 5, CI role, inventory rows) + operator-runbook-harness.md (On CI, flag table, phase-5 bullet, role mapping).
…dentity fetch never could The mock-wire CI proof (first headless run of the post-wire agent runtime) caught a REAL #216 gap, not a harness bug: the cred worker keys S3 strictly by cap.payload.actor_omni, so an agent-identity fetch (actor=agent) read bots/<agent>/credentials/ — but the #216 flow vaults the key MASTER-SELF into bots/<operator>/credentials/. 502 s3_get. Every prior #216 proof was master-self (operator==actor → same prefix), and phase1's old host fetch swallowed the failure (2>/dev/null → env fallback), so 'the agent fetches from the master's vault' had never actually worked end-to-end. Fix (worker, fetch only): try the actor's OWN vault first (#228 agent-owned creds — self-stored entries shadow delegated ones), then, for a DELEGATED cap (actor != operator), fall back to the OPERATOR's vault. The envelope AAD is keyed by the vault OWNER (each vault's objects were encrypted with aad(operator, owner, service, epoch) at store time), so decrypt matches either source. No IAM change: the S3 read still runs under the caller-relayed STS — reading the operator prefix requires the operator session the wire context already holds; the (device-bound, isServiceInScope-verified) cap narrows WHICH service that session releases. Store/teardown/list stay strictly actor-keyed. Unit tests: fetch_vault_owners (master-self = one vault, byte-identical prior behavior; delegated = actor-then-operator). arch.md synced (credential_envelope row + the cred-fetch sequence) per the architecture-as-source-of-truth policy. CI self-verifies: crates/agentkeys-worker-*/** trips the paths-filter → the test EC2 auto-redeploys → the harness (incl. phase-5 mock wire step 6) runs against the fixed worker.
… agent file heima-agent-create.sh read .agent_private_key and fed it straight to `cast wallet sign`. When the file is a §10.2 sandbox-paired record (key_custody=sandbox-only, agent_private_key:null — written by the wire phase's in-sandbox device-session), AGENT_KEY became the literal 'null' → 'Error: Failed to decode private key' at stage-1 step 12. The wire phase and stage-1 share the 'demo-agent' label, so any operator who runs the wire then re-runs stage 1 hits this. Now: validate the key shape (0x+64hex or bare 64hex); if unusable (sandbox custody / corrupt / legacy), back up the file to <label>.json.bak.<ts> and regenerate a fresh master-held wallet — i.e. behave as a clean machine would. Non-destructive to §10.2: the sandbox holds the authoritative key and phase-5 pairing rebuilds the master-side record. Shape guard tested standalone under set -e (null/empty/short/ non-hex → regenerate; valid → reused, bare key 0x-normalized). Runbook fold-back: two new Q&A entries (the decode error + the earlier QR-picker-instead-of-Touch-ID passkey case).
…eth_chainId curl (transient-RPC hardening) A transient RPC blip (LibreSSL SSL_ERROR_SYSCALL to rpc.heima-parachain) made the inline `LIVE_CHAIN_ID=$(printf '%d' "$(curl … eth_chainId …)")` resolve to 0, and `cast send --chain-id 0` was then rejected with 'error code -32603: invalid chain id' (hit at v2-stage1 step 14, heima-credential-audit.sh). 13 heima-*.sh scripts shared this fragile pattern — any of them could fail the same way on a blip. All 13 now resolve the chain id from the OFFLINE pinned profile (`echo "$PROFILE_JSON" | jq -r '(.chain_id // 0)'` — PROFILE_JSON is already loaded in each) and fail loud if it isn't a positive integer, so a network blip can never corrupt the --chain-id passed to cast. The live eth_chainId is still cross-checked once at flow start (v2-stage1 step 5 / heima-bring-up.sh), so wrong-RPC detection is unchanged. Inline (not a _lib.sh helper) on purpose: the chain_id line runs early in each script while _lib.sh is sourced later for resolve_master_key — a helper call would be a call-before-definition bug in 9 of 13. Inline needs only PROFILE_JSON + die, both present before the line in all 13 (verified). Functional-tested against the real heima profile (→ 212013) + the null/0/missing fail-loud paths; bash -n all 13. Runbook Q&A added.
…ct change)
The on-chain AgentKeysScope stores only keccak(service) HASHES, so an
agent can verify a known service name (isServiceInScope) but cannot
enumerate its authorized NAMES or learn which is its default LLM key
from chain. Putting names/a-default on-chain (a contract change) is
unnecessary: the master KNOWS the plaintext names + default at grant
time. Record them OFF-CHAIN; the on-chain hash gate still runs on every
fetch, so authorization is unchanged — this is discovery only.
- agentkeys-types: CredManifest { services, default_service } + resolve
precedence (explicit > --select N (1-based) > master default) +
load/save. 11 unit tests.
- agentkeys cred manifest --services a,b,c --default a → the master
records the authorized names + designated default (public names only).
- agentkeys cred list → the agent's off-chain discovery (the chain
can't enumerate); marks the default.
- agentkeys cred fetch → service is now OPTIONAL: no arg uses the
master-designated default (the #216 no-UI path), --select N picks from
the list, an explicit service is used as-is (still on-chain verified).
Smoke-tested: manifest write → list (default marked) → no-arg fetch
resolves the default before the network call → --select 1 picks the
first → --select 9 clean range error. fmt + clippy clean; types 47 /
cli 19 tests green; backend fixture gate unaffected (not a wire shape).
Harness e2e proof + docs follow in the next commit.
…I path) Wires the off-chain cred manifest through the agent runtime so the no-UI default-key path is proven end-to-end: - phase1-wire-demo.sh 1.4b': the master records the sandbox cred manifest (agentkeys cred manifest --services <granted creds> --default $SERVICE → ~/.agentkeys/cred-manifest.json) so the in-sandbox no-arg fetch resolves the designated default. Skips gracefully on an older sandbox binary. - sandbox-agent-isolation.sh: + the #216 default-key proof — cred list (off-chain discovery the chain can't do) + a bare 'cred fetch' (no service) asserting it resolves the master default to the SAME secret as the explicit fetch. Conditional on a manifest (the explicit cred half still stands alone). - mock-wire-demo.sh: writes a temp manifest + points the isolation run at it via AGENTKEYS_CRED_MANIFEST (never the runner's real ~/.agentkeys), so CI proves the default-key path headless too. Docs (same change, keep-in-sync): arch.md (off-chain default-key = discovery, on-chain = verification), user-manual.md (cred manifest/list/default verbs), harness/CLAUDE.md (sandbox role + phase1 + mock-wire rows), operator-runbook-harness.md (On Sandbox proof). Detection greps verified against real CLI output; bash -n all three scripts. The CLI core (types + verbs) is the prior commit.
…control-vs-secrets, off-chain spend caps New docs/wiki/master-recovery-and-guardians.md (indexed in Home under Foundations). Came out of the architecture Q&A: the recovery process wasn't documented in the wiki. Covers: - M-of-N guardian social recovery is EXECUTED on-chain by P256Account.recover() (in-contract WebAuthn verify, threshold + dedup + replay-bound challenge, atomic signer rotation, permissionless submit) — the chain is the executor here, not just an audit log. - Setup (addGuardian + recoveryThreshold) + the K11-gated ceremonies. - The lost-device timeline (revoke/rotate → broker SSE cap-drop → daemon cache zero). - Control vs. secrets boundary: recover() restores CONTROL on-chain instantly; decrypting EXISTING vaulted secrets needs the TEE to re-wrap the K3 KEK (on-chain-coordinated, TEE-executed). - The dev escape hatch (resetMaster) vs. real guardian recovery. - Why spend caps are enforced OFF-chain (per the user's point): spend is a HIGH-FREQUENCY hot path, so the limits are on-chain (policy) but the usage accumulator is off-chain (meter) — the inverse of recovery (rare + high-stakes → on-chain execution worth the gas). States the principle + the future on-chain-accumulator option gated on frequency. All 15 cross-links verified to resolve; wiki lint clean (no frontmatter, no H1). Defers to arch.md §11 for the canonical spec.
…, not 'init died early' agentkeys-init-email-demo.sh fires `agentkeys init --email` in the background and its poll loop treats ANY early exit as failure. But cmd_init_with_force returns Ok(existing) IMMEDIATELY (exit 0, no email sent) when a usable session already exists, printing 'Already initialized as 0x… (run --force)'. So a valid existing session was mis-reported as 'init died early (likely broker rejection)' and the script polled S3 forever for an email that was never sent (v2-stage1 step 6). The fast-fail block now reaps the exit code and, when init exited 0 with 'Already initialized' in its log, reuses the on-disk session and exits 0 — the demo's goal (a live session for this --session-id) is already met. A real non-zero exit / broker rejection still dies as before. Trigger: a session aged past do_step_6's 1-hour reuse window but still within the broker TTL → do_step_6 re-inits → init short-circuits. Re-run 'bash harness/v2-demo.sh --from 1.6' (now green). Runbook Q&A added. bash -n clean; grep verified against the exact CLI message.
… ring buffer + S3 archive + daemon bridge Tier 2 (default-on, AGENTKEYS_AUDIT_BATCH_SECONDS=120): the audit worker anchors each per-operator V2 batch autonomously — AuditRootAnchor (90) envelope, hash committed via ungated CredentialAudit.appendV2 signed by the relay EOA (appendRootV2's master gate is unreachable for a hosted relay: MasterMustBeAccount + Touch-ID masters can't sign on a timer). Retry x3 exp backoff; persistent failure re-queues the batch + emits AuditBatchFailed (91). Zero contract change (open-enum §15.3b). Tier 1: per-actor ring buffers (1000), GET /v1/audit/stream SSE with backfill, GET /v1/audit/anchors/:op (per-entry Merkle proofs — the tamper check), GET /v1/audit/relay-info; AuditFeedEvent shape owned by agentkeys-types (#203 one-owner). S3 cold archive (env-gated) restores rings on boot + backs get_envelope across restarts. Daemon: worker-feed SSE bridge folds worker-side events into the existing ApiAuditEvent web feed (dedup by envelope hash) and flips /v1/anchor/status to REAL on anchor events. Deploy: setup-broker-host.sh generates the relay key (0600, preserved) + rewrites worker-audit.env (cadence/chain/bucket) + SSE-safe nginx location; provision-audit-archive.sh (bucket + instance-role grant) wired into setup-cloud.sh step 13; heima-fund-audit-relay.sh wired into setup-heima.sh step 14; AUDIT_BUCKET in both env files + CI materializer. legacy_tx moved bundler→core (shared EOA signer).
… feed/anchor/tamper legs + UI chips - Anti-spam gate: anchors submit only for operators with a registered on-chain master (eth_call operatorMasterWallet, TTL-cached 10min/60s); unregistered batches drop with a WARN (envelopes stay fetchable by hash), transient RPC failures re-queue. Without this the open append/v2 endpoint lets fake operator omnis each burn one relay tx per tick. - HTTP flush handlers SPAWN anchoring (response never waits out a chain confirmation — existing --max-time 10 callers keep working); response carries anchor_scheduled; consumers poll /v1/audit/anchors. The timer path still awaits anchors inline. - heima-worker-smoke.sh: #109 legs — SSE backfill must carry the appended envelope; idempotent relay top-up before flush; poll the anchor record (<=90s), cast-receipt confirm the appendV2 tx, walk the Merkle proof in bash (genuine verifies, tampered leaf FAILS — the #109 tamper check). Tolerated skips: relay-not-configured / anchor-not-recorded. - parent-control: 'worker' + 'anchor' ChipKinds (styles + filter row). - operator-runbook-harness.md: smoke two-tier wiring documented. - service/anchor tests: registered-operator end-to-end anchor (fake RPC), spam-omni drop, RPC-outage re-queue.
…gates The #209 tripwire fired on this branch's run exactly as designed: the test config worker became reachable (this PR's broker redeploy converged it) and stage-3 steps 19 (config bucket/role write + cross-bucket denials) and 21 (cross-data-class cap -> 403 cap_data_class_mismatch) both ran LIVE and passed. Per the guard's own instructions: drop config-role-missing + config-worker-unreachable from the v2-demo --allow-skip and delete the now-inert self-dissolving Guard step. The demo's skip reasons remain for operator-local runs; CI now fails closed on config-worker drift.
…est stack The worker-audit env block passed the env-aware CredentialAudit address but not the registry, so the anti-spam gate fell back to the compiled-in profile (PROD registry) — on the test stack operatorMasterWallet(test-omni) is zero there and every batch dropped as 'unregistered' (the anchor-not-recorded skip in the first green harness run; relay was funded, anchor_enabled=true). Pass AGENTKEYS_AUDIT_REGISTRY_ADDRESS=$REGISTRY_ADDR like the other workers' SIDECAR_REGISTRY_ADDRESS_HEIMA.
… cast version The previous CI run PROVED the #109 anchor loop live (registry-gate fix worked: the relay anchored within seconds and the poll found the record first try) — the leg then died on its own assertion: 'status: true' did not match the *1*-only pattern. Accept true|1|0x1; false/0x0 still dies.
…st-status lessons folded)
…t-7e7a4b # Conflicts: # .github/workflows/harness-ci.yml # crates/agentkeys-types/src/lib.rs # docs/operator-runbook-harness.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #109. Closes #209 — the config-worker quarantine tripwire fired on this branch's harness run (config-test reachable; stage-3 steps 19 + 21 ran LIVE and passed), so per the guard's own instructions the
config-role-missing/config-worker-unreachableallowances and the self-dissolving Guard step are removed; CI now fails closed on config-worker drift. Builds on the #229 (data-plane emits + V2 queues) and #97/#270 (control-plane emits + receipts) substrate, and closes the #229-deferred open design item "audit-worker-initiated chain submission (tier-A relay wallet)". Plan:docs/plan/issue-109-two-tier-audit.md.Design decision — anchor via ungated
appendV2+AuditRootAnchor(90), notappendRootV2appendRootV2requiresmsg.sender == operatorMasterWallet(omni), the registry rejects EOA masters (MasterMustBeAccount), and a prod master is a Touch-ID passkey that cannot sign on a 2-minute timer — so the documented "operator master commits the root" path can never run autonomously. Instead the relay wraps each batch root in an honestAuditRootAnchorenvelope (body:{merkle_root, op_kind_bitmap, entry_count, relay_address}) and commits that envelope's hash via the ungatedappendV2(realOperatorOmni, relayActorOmni, 90, envelopeHash)— one legacy tx per batch, the REAL operator omni stays an indexed topic, zero contract change (the §15.3b open-enum design's whole point). Genuine anchors are distinguished from spam bytx.from == relay_address(published atGET /v1/audit/relay-info), exactly arch.md §15.3 tier A's trust model ("only shared service-relay-wallet" on chain). The master-gatedappendRoot/appendRootV2remains the sovereign tier-B/C route (heima-worker-smoke.shstill exercises it). Rejected alternatives (contract relay-allowlist = mainnet redeploy ceremony; software-P256Account relay = re-couples audit to the bundler #241 decoupled) are recorded in the plan doc.What landed (all 10 plan steps)
AuditRootAnchor+ 91AuditBatchFailedper the §15.3b ritual —crates/agentkeys-core/src/audit/{op_kind,bodies,mod}.rs, CBOR roundtrip tests, vector exporter rows, arch.md table rows (family 90-99 claimed).legacy_txmoved bundler → core (crates/agentkeys-core/src/legacy_tx.rs, bundler re-exports) — one EOA signer for both emitters.crates/agentkeys-worker-audit/src/anchor.rs: chain-profile-driven config (AGENTKEYS_AUDIT_RELAY_KEY_FILE, RPC/contract env overrides for the isolated test stack), raw JSON-RPC (Heima mixHash-safe), retry ×3 exponential backoff, receipt polling;AGENTKEYS_AUDIT_BATCH_SECONDS(default 120, legacy…FLUSH_INTERVAL_SECShonored); degraded log-only boot when unconfigured (feat: #230 bundler decouple (re-land #238) + wallet/funding docs, rotation runbook & balance monitor #241 posture). Persistent failure re-queues the batch + emitsAuditBatchFailedinto store+queue+feed + ERROR log. Anti-spam gate: anchors only for operators with a registered on-chain master (TTL-cachedoperatorMasterWalleteth_call) — the openappend/v2endpoint can no longer make fake omnis burn relay gas; transient RPC failures re-queue, never drop.GET /v1/audit/streamSSE (operator/actor filter + backfill),GET /v1/audit/anchors/:operator(per-entry Merkle proofs),GET /v1/audit/relay-info. Feed shape has ONE owner:agentkeys_types::audit_feed::AuditFeedEvent(Shared broker/worker client crate — collapse the duplicated chain impls (drift fix) #203 rule). HTTP flush handlers spawn anchoring (a flush response never waits out a chain confirmation); the timer awaits inline.crates/agentkeys-worker-audit/src/archive.rs(env-gatedAGENTKEYS_AUDIT_S3_BUCKET): async PUTs of feed events + envelope CBOR, boot-time ring restore (the "last 1000/actor survive restart" criterion),get_envelopecold fallback.ui_bridge.rs: SSE client folds worker events into the existingApiAuditEventweb feed (dedup by envelope hash, either delivery order), flips/v1/anchor/statusto REAL on anchor events; reconnect w/ backoff, session-change aware. Plusworker/anchorchips inapps/parent-control(typed, filter row).setup-broker-host.sh: relay key generated once (0600, preserved — rotation would orphan the funded account), worker-audit env block (cadence/chain/contract/bucket), SSE-safe nginx location (proxy_buffering off, 1 h read timeout).scripts/provision-audit-archive.sh(dedicated audit bucket per the per-data-class rule + instance-role inline grant, EIP-tag-resolved) ←setup-cloud.shstep 13;scripts/heima-fund-audit-relay.sh(reads relay-info, delegates to the idempotent funder) ←setup-heima.shstep 14;AUDIT_BUCKETin both env files + the CI materializer (the Config data class + lazy, config-driven memory list (Phases 1–5) #201 hard rule).heima-worker-smoke.shPhase 1: Two-tier audit wiring (real-time off-chain feed + 2-min on-chain anchor) #109 legs: SSE backfill must carry the appended envelope; idempotent relay top-up; poll the anchor record ≤90 s;cast receiptconfirms the tx; bash Merkle-proof walk verifies the genuine envelope AND proves a tampered one fails (the acceptance tamper test). Tolerated skips (relay-not-configured,anchor-not-recorded) keep degraded hosts green. Runbook updated in the same change.docs/user-manual.md"Live audit feed + on-chain anchor badge".Tests: 22 worker-audit unit (incl. full flush→gate→anchor→feed against a fake RPC node, spam-drop, outage-requeue, ring caps, proof verify/tamper) + 7 integration, 191 core, 93 daemon ui_bridge (SSE parser, mapper, dedup-either-order, real-socket pump→anchor-status). fmt/clippy clean; fixture-drift, env-mutation, contracts-sync, web-api-drift gates all pass.
What did NOT land
Live CI verification of the anchor loopDONE in this PR's CI run (Heima mainnet, test stack): SSE backfill ✓, anchor txs confirmed on-chain (e.g.0x9f0d95c3…79bf27) ✓, genuine-envelope Merkle proof ✓, tampered-event proof FAILS ✓ — in both the stage-1 and stage-2 smoke runs. Two live-run fixes folded in: the anchor gate now gets the env-aware SidecarRegistry (the compiled-in prod registry dropped every test batch as "unregistered"), and the smoke'scast receipt statusmatch acceptstrue|1|0x1. Prod verification remains the operator's post-merge redeploy (commands below).subscan-essentialsexplorer renderers for 90/91 — external repo (subscan-essentials#12); until then they render via theUnknown(byte)fallback (invariant v0.1: TEE-side per-session read rate limit (abuse defense) #4), and the new export vectors are ready for it.heima-worker-smoke.sh(run by setup-heima step 14 + stage-2) instead of a new stage-3 step — avoids renumbering the 23-step demo; stage-3 11-12 already assert the receipt-fetch path.To test this
bash scripts/setup-broker-host.sh --ref mainon the broker hostbash scripts/setup-cloud.sh --only-step 13(laptop,agentkeys-admin); add--cifor the test stackbash scripts/setup-heima.sh --only-step 14(orbash scripts/heima-fund-audit-relay.sh)dev.sh; no broker dependency.soltouched, noVERSIONbump, no redeploy🤖 Generated with Claude Code