diff --git a/apps/parent-control/app/_components/ChainBadge.tsx b/apps/parent-control/app/_components/ChainBadge.tsx new file mode 100644 index 00000000..485a0c34 --- /dev/null +++ b/apps/parent-control/app/_components/ChainBadge.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useClient, useConnectionStatus } from '@/lib/ClientProvider'; +import type { ChainInfo } from '@/lib/client/types'; + +/** + * Persistent top-right badge: the chain + RPC node the daemon operates against. + * + * Sourced LIVE from `GET /v1/chain/info` (the daemon's resolved chain profile) — + * never hardcoded. Mounted in the root layout so it shows on EVERY screen, the + * onboarding flow included (App.tsx early-returns the onboarding screen before its + * own chrome renders, so an in-app indicator alone is invisible there). When the + * daemon is unreachable it shows "daemon offline" rather than any baked-in default. + */ +export function ChainBadge() { + const client = useClient(); + const status = useConnectionStatus(); + const [info, setInfo] = useState(null); + + useEffect(() => { + let cancelled = false; + const load = async () => { + try { + const r = await client.getChainInfo(); + if (!cancelled && r.ok) setInfo(r.data); + } catch { + // daemon unreachable — keep the last known value; the dot shows offline. + } + }; + void load(); + const id = setInterval(() => void load(), 20_000); + return () => { + cancelled = true; + clearInterval(id); + }; + }, [client]); + + const online = status.kind === 'connected'; + const rpcHost = info ? hostOf(info.rpc) : null; + + return ( +
+ + {info ? ( + <> + {info.display || info.name} + · + #{info.chainId} + {rpcHost ? ( + <> + · + {rpcHost} + + ) : null} + + ) : ( + {online ? 'chain…' : 'daemon offline'} + )} +
+ ); +} + +/** Show just the host of an RPC URL (e.g. `base-rpc.publicnode.com`); the full URL is in the title. */ +function hostOf(url: string): string { + try { + return new URL(url).host; + } catch { + return url; + } +} diff --git a/apps/parent-control/app/globals.css b/apps/parent-control/app/globals.css index bba8f590..836d7200 100644 --- a/apps/parent-control/app/globals.css +++ b/apps/parent-control/app/globals.css @@ -847,3 +847,35 @@ a:hover { text-decoration-color: var(--ink); } .perm-row > .perm-seg, .perm-row > .perm-switch, .perm-row > .perm-readonly { grid-column: 2; justify-self: start; margin-top: 6px; } .perm-icon { width: 30px; height: 30px; font-size: 14px; } } + +/* ─── Global chain + RPC badge (fixed top-right, every screen incl. onboarding) ── */ +/* Rendered by app/_components/ChainBadge.tsx from GET /v1/chain/info — never hardcoded. */ +.chain-badge { + position: fixed; + top: 8px; + right: 10px; + z-index: 1000; + display: flex; + align-items: center; + gap: 6px; + max-width: min(54vw, 480px); + padding: 4px 9px; + border: 1px solid var(--rule-soft); + border-radius: 6px; + background: color-mix(in oklab, var(--bg-elev) 86%, transparent); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); + font: 11px/1.2 'IBM Plex Mono', ui-monospace, monospace; + letter-spacing: 0.02em; + color: var(--ink-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.chain-badge strong { font-weight: 600; color: var(--ink); } +.chain-badge__dot { width: 7px; height: 7px; border-radius: 50%; flex: 0 0 auto; } +.chain-badge__sep { color: var(--ink-faint); } +.chain-badge__muted { color: var(--ink-faint); } +@media (max-width: 560px) { + .chain-badge { font-size: 10px; max-width: 70vw; top: 6px; right: 6px; } +} diff --git a/apps/parent-control/app/layout.tsx b/apps/parent-control/app/layout.tsx index b716cfb5..261c75a3 100644 --- a/apps/parent-control/app/layout.tsx +++ b/apps/parent-control/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata, Viewport } from 'next'; import { ClientProvider } from '@/lib/ClientProvider'; +import { ChainBadge } from './_components/ChainBadge'; import './globals.css'; export const metadata: Metadata = { @@ -26,7 +27,10 @@ export default function RootLayout({ children }: { children: React.ReactNode }) /> - {children} + + + {children} + ); diff --git a/crates/agentkeys-broker-server/src/handlers/accept.rs b/crates/agentkeys-broker-server/src/handlers/accept.rs index e05ac9b4..5c4d73d0 100644 --- a/crates/agentkeys-broker-server/src/handlers/accept.rs +++ b/crates/agentkeys-broker-server/src/handlers/accept.rs @@ -302,10 +302,13 @@ pub fn load_accept_config() -> Result<(AcceptConfig, SigningKey), String> { // #278 D6: verificationGasLimit / preVerificationGas are env > profile > default // (shared by accept / scope / revoke / register). The live RIP-7212 precompile - // (#170 / #288) makes the on-chain P-256 verify ~3.4k gas, so a precompile chain - // (Base) carries ~200k in its profile; chains still on the pure-Solidity verifier - // keep the 1.5M default. Lowering blind on a non-precompile chain reverts inside - // validateUserOp as a false SIG_VALIDATION_FAILED (#225 / gap #7). + // (#170 / #288) makes the on-chain P-256 verify ~3.4k gas — BUT the D6 register + // also DEPLOYS the P256Account inside the UserOp (initCode), and the EntryPoint + // runs that deploy within verificationGasLimit (~1.3M on Base). So the value must + // cover the DEPLOY, not just the verify: Base carries ~1.6M in its profile even + // with the cheap precompile (the earlier 200k OOG'd the deploy → AA13). Chains on + // the pure-Solidity verifier keep the 1.5M default. Lowering it below the real + // cost reverts inside validateUserOp as a false SIG_VALIDATION_FAILED (#225 / gap #7). // Finalize chain_id: explicit env wins, else the compiled profile's id, else a // loud-warned heima fallback (only reachable on a custom chain with no env + no // built-in profile — exactly the case the old silent 212013 default mis-signed). @@ -332,6 +335,22 @@ pub fn load_accept_config() -> Result<(AcceptConfig, SigningKey), String> { prof_pvg, DEF_PRE_VERIFICATION_GAS, ); + // gas_fees come from the chain's real fee market (its compiled profile, gwei→wei), + // with the broker DEF as the fallback for a chain with no usable profile fee + // (legacy/anvil maxFee=0). The old hardcoded 40 gwei is Heima's base-fee scale; on + // Base (~0.005 gwei base) it reserved a ~0.16 ETH UserOp prefund the paymaster + // deposit can't cover — a false AA31 the moment the AA13 deploy-gas was fixed. + let (max_priority_fee, max_fee) = resolved_profile + .as_ref() + .map(|p| (p.gas.max_priority_fee_gwei, p.gas.max_fee_gwei)) + .filter(|&(_, max_fee_gwei)| max_fee_gwei > 0) + .map(|(prio_gwei, max_fee_gwei)| { + ( + prio_gwei as u128 * 1_000_000_000, + max_fee_gwei as u128 * 1_000_000_000, + ) + }) + .unwrap_or((DEF_MAX_PRIORITY_FEE, DEF_MAX_FEE)); let cfg = AcceptConfig { rpc_url, @@ -346,7 +365,7 @@ pub fn load_accept_config() -> Result<(AcceptConfig, SigningKey), String> { DEF_CALL_GAS_LIMIT, ), pre_verification_gas: u256_word(pre_verification_gas_amt), - gas_fees: crate::sponsor::pack_u128_pair(DEF_MAX_PRIORITY_FEE, DEF_MAX_FEE), + gas_fees: crate::sponsor::pack_u128_pair(max_priority_fee, max_fee), paymaster_verification_gas_limit: DEF_PAYMASTER_VERIFICATION_GAS, paymaster_post_op_gas_limit: DEF_PAYMASTER_POST_OP_GAS, }; @@ -796,6 +815,15 @@ pub async fn accept_submit( ) .await .map_err(|e| { + // Log on the broker host: the submit error was previously returned to the + // caller WITHOUT tracing, so a bundler rejection (e.g. a wrong-chain-id outer + // tx the RPC refuses) left an empty broker journal and a fast, silent 502. + tracing::warn!( + target: "agentkeys.broker.accept", + bundler = %bundler_url, + error = %e, + "submit relay: bundler eth_sendUserOperation failed — UserOp NOT broadcast" + ); aerr( StatusCode::BAD_GATEWAY, format!("handleOps did not broadcast: {e}"), @@ -847,6 +875,12 @@ pub async fn accept_submit( // paymaster deposit too low") — map it to operator guidance // instead of guessing "wrong passkey". let reason = receipt.get("reason").and_then(|r| r.as_str()); + tracing::warn!( + target: "agentkeys.broker.accept", + tx = %tx_hash, + reason = reason.unwrap_or("(none)"), + "submit relay: handleOps reverted on-chain" + ); return Err(aerr( StatusCode::BAD_GATEWAY, handle_ops_revert_message(reason, &tx_hash), diff --git a/crates/agentkeys-bundler/src/main.rs b/crates/agentkeys-bundler/src/main.rs index 403bf468..7ec1082f 100644 --- a/crates/agentkeys-bundler/src/main.rs +++ b/crates/agentkeys-bundler/src/main.rs @@ -6,7 +6,7 @@ //! ENTRYPOINT_ADDRESS[_HEIMA] = 0x… (absent ⇒ degraded) //! AGENTKEYS_BUNDLER_SIGNER_KEY = 0x… (or BROKER_SPONSOR_SIGNER_KEY; absent ⇒ degraded) //! Optional: -//! AGENTKEYS_CHAIN_ID[_HEIMA] (default 212013) +//! AGENTKEYS_CHAIN_ID[_] (override; else the compiled chain profile for AGENTKEYS_CHAIN — no Heima default) //! AGENTKEYS_HANDLEOPS_GAS_LIMIT (default 4000000 — Heima can't estimate handleOps) //! AGENTKEYS_BUNDLER_GAS_PRICE (wei; default eth_gasPrice +25%) //! AGENTKEYS_BUNDLER_BIND (default 127.0.0.1:9098 — loopback-only, PRIVATE) diff --git a/crates/agentkeys-bundler/src/server.rs b/crates/agentkeys-bundler/src/server.rs index 1029a202..1a28f122 100644 --- a/crates/agentkeys-bundler/src/server.rs +++ b/crates/agentkeys-bundler/src/server.rs @@ -30,6 +30,21 @@ fn env_profile(base: &str) -> Result { .map_err(|_| anyhow!("env {base}[_{p}] not set")) } +/// Chain id from the compiled-in chain profile for `AGENTKEYS_CHAIN` — the SAME +/// source of truth the broker resolves against. A host that sets only +/// `AGENTKEYS_CHAIN=base` therefore gets Base's `8453`, never the legacy Heima +/// default that silently made the bundler sign + broadcast `handleOps` for the +/// wrong chain (the Base register-never-broadcast bug). Returns the id as a +/// string so it flows through the same `BundlerBootValues.chain_id` field as an +/// explicit `AGENTKEYS_CHAIN_ID[_]` override; `None` only for an unknown +/// chain with no compiled profile, which `from_values` then rejects (fail loud). +fn chain_id_from_chain_profile() -> Option { + let chain = std::env::var("AGENTKEYS_CHAIN").unwrap_or_else(|_| "heima".into()); + agentkeys_core::chain_profile::ChainProfile::load_builtin(&chain) + .ok() + .map(|p| p.chain_id.to_string()) +} + fn addr20(hex_s: &str, name: &str) -> Result<[u8; 20]> { hex::decode(hex_s.trim().trim_start_matches("0x")) .map_err(|e| anyhow!("{name}: {e}"))? @@ -77,7 +92,9 @@ pub enum BundlerBoot { pub struct BundlerBootValues { /// `AGENTKEYS_CHAIN_RPC_HTTP`. pub rpc_url: Option, - /// `AGENTKEYS_CHAIN_ID[_]` (default 212013 when absent/unparseable). + /// `AGENTKEYS_CHAIN_ID[_]` override, else the compiled chain profile + /// for `AGENTKEYS_CHAIN` (resolved in `from_process_env`). No Heima default — + /// an unresolved id makes `from_values` fail loud rather than mis-sign. pub chain_id: Option, /// `ENTRYPOINT_ADDRESS[_]`. pub entry_point: Option, @@ -94,7 +111,11 @@ impl BundlerBootValues { pub fn from_process_env() -> Self { Self { rpc_url: std::env::var("AGENTKEYS_CHAIN_RPC_HTTP").ok(), - chain_id: env_profile("AGENTKEYS_CHAIN_ID").ok(), + // Explicit override first (profileless chains / local dev), else the + // compiled chain profile for AGENTKEYS_CHAIN (SoT). No Heima default. + chain_id: env_profile("AGENTKEYS_CHAIN_ID") + .ok() + .or_else(chain_id_from_chain_profile), entry_point: env_profile("ENTRYPOINT_ADDRESS").ok(), signer_key: std::env::var("AGENTKEYS_BUNDLER_SIGNER_KEY") .or_else(|_| std::env::var("BROKER_SPONSOR_SIGNER_KEY")) @@ -120,10 +141,23 @@ impl BundlerBoot { /// config, always pinned in the systemd unit). pub fn from_values(values: BundlerBootValues) -> Result { let rpc_url = values.rpc_url.context("env AGENTKEYS_CHAIN_RPC_HTTP")?; + // No silent default. The legacy `unwrap_or(212_013)` made a Base (or any + // non-Heima) host quietly sign + broadcast `handleOps` for Heima's chain + // id, which the target RPC rejects — the Base master-register UserOp never + // broadcast (submitter nonce stayed 0; a fast, silent 502). `from_process_env` + // fills this from an explicit AGENTKEYS_CHAIN_ID[_] override or the + // compiled chain profile; an unresolved/garbled id is a hard misconfig. let chain_id: u64 = values .chain_id - .and_then(|s| s.parse().ok()) - .unwrap_or(212_013); + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .context( + "bundler chain id unresolved — set AGENTKEYS_CHAIN_ID[_] or run with an \ + AGENTKEYS_CHAIN that has a compiled chain profile; refusing to assume Heima (212013)", + )? + .parse() + .context("AGENTKEYS_CHAIN_ID must be a u64 chain id")?; let mut missing = Vec::new(); let entry_point = match values.entry_point.filter(|s| !s.trim().is_empty()) { Some(s) => Some(addr20(&s, "ENTRYPOINT_ADDRESS")?), @@ -739,6 +773,10 @@ mod tests { rpc_url: rpc.map(str::to_string), entry_point: entry_point.map(str::to_string), signer_key: signer_key.map(str::to_string), + // chain id is resolved (override or compiled profile) BEFORE from_values; + // pin Heima's here so these tests exercise EntryPoint/key branching, not + // chain-id resolution. + chain_id: Some("212013".into()), ..Default::default() } } @@ -813,4 +851,27 @@ mod tests { BundlerBoot::Degraded { missing, .. } => panic!("expected Ready, missing={missing:?}"), } } + + #[test] + fn from_values_rejects_unresolved_chain_id() { + // The old code silently defaulted a missing chain id to Heima's 212013, so a + // Base host signed handleOps for the wrong chain. Now unresolved ⇒ hard error. + let key = format!("0x{}", "46".repeat(32)); + let ep = format!("0x{}", "66".repeat(20)); + let mut v = boot_values(Some("http://127.0.0.1:1"), Some(&ep), Some(&key)); + v.chain_id = None; + assert!(BundlerBoot::from_values(v).is_err()); + } + + #[test] + fn compiled_profiles_pin_base_and_heima_chain_ids() { + use agentkeys_core::chain_profile::ChainProfile; + // The SoT the bundler now resolves against: a Base host MUST sign for 8453, + // a Heima host for 212013 — never a hardcoded default. + assert_eq!(ChainProfile::load_builtin("base").unwrap().chain_id, 8453); + assert_eq!( + ChainProfile::load_builtin("heima").unwrap().chain_id, + 212_013 + ); + } } diff --git a/crates/agentkeys-core/chain-profiles/base.json b/crates/agentkeys-core/chain-profiles/base.json index b3597cfa..c9e2ba63 100644 --- a/crates/agentkeys-core/chain-profiles/base.json +++ b/crates/agentkeys-core/chain-profiles/base.json @@ -25,8 +25,8 @@ "gas": { "model": "eip1559", "max_priority_fee_gwei": 1, - "max_fee_gwei": 50, - "verification_gas_limit": 200000 + "max_fee_gwei": 1, + "verification_gas_limit": 1600000 }, "deploy": { "deployer_env_var": "AGENTKEYS_BASE_DEPLOYER_KEY", @@ -95,8 +95,8 @@ ], "funding": { "deploy_min_wei": "3000000000000000", - "paymaster_deposit_wei": "1000000000000000", - "paymaster_min_deposit_wei": "1000000000000000", + "paymaster_deposit_wei": "20000000000000000", + "paymaster_min_deposit_wei": "8000000000000000", "ed_buffer_wei": "0", "account_deposit_wei": "5000000000000000", "userop_max_fee_wei": "1000000000" diff --git a/crates/agentkeys-core/chain-profiles/heima.json b/crates/agentkeys-core/chain-profiles/heima.json index f984d97a..2bae7f8c 100644 --- a/crates/agentkeys-core/chain-profiles/heima.json +++ b/crates/agentkeys-core/chain-profiles/heima.json @@ -32,7 +32,7 @@ "gas": { "model": "eip1559", "max_priority_fee_gwei": 1, - "max_fee_gwei": 1000 + "max_fee_gwei": 40 }, "deploy": { "deployer_env_var": "AGENTKEYS_HEIMA_DEPLOYER_KEY", diff --git a/crates/agentkeys-core/src/chain_profile.rs b/crates/agentkeys-core/src/chain_profile.rs index 28c057a4..19da1b09 100644 --- a/crates/agentkeys-core/src/chain_profile.rs +++ b/crates/agentkeys-core/src/chain_profile.rs @@ -260,16 +260,25 @@ pub struct FinalityConfig { pub struct GasConfig { /// `"eip1559"` or `"legacy"`. Anvil + some local dev chains use legacy. pub model: String, + /// `maxPriorityFeePerGas` (gwei) for master UserOps. Wired into the op's + /// gas_fees by the broker (gwei→wei), falling back to the broker default when a + /// profile has no usable fee (legacy/anvil `max_fee_gwei == 0`). pub max_priority_fee_gwei: u64, + /// `maxFeePerGas` (gwei) for master UserOps — the chain's real fee ceiling, NOT + /// a blanket value. It sizes the EntryPoint prefund reserve (× total gas), so on + /// a cheap L2 (Base ~0.005 gwei base fee → ~2 here) a Heima-scale 40+ gwei would + /// reserve a prefund the paymaster deposit can't cover (false AA31). pub max_fee_gwei: u64, /// ERC-4337 UserOp `verificationGasLimit` for master ops (accept / scope / - /// revoke / #278 D6 register). The account's `validateUserOp` runs an - /// on-chain P-256 verify; on a chain with the live RIP-7212 precompile - /// (#170 / #288) that is ~3.4k gas, so precompile chains set ~200k here while - /// chains still on the pure-Solidity verifier keep the broker's 1.5M default. - /// `None` ⇒ the broker default. NEVER set it below the real verify cost: - /// under-gas reverts inside `validateUserOp` as a false `SIG_VALIDATION_FAILED` - /// (#225 / gap #7). Env `ACCEPT_VERIFICATION_GAS_LIMIT[_]` overrides it. + /// revoke / #278 D6 register). `validateUserOp` runs an on-chain P-256 verify + /// (~3.4k gas on a RIP-7212 precompile chain, #170 / #288) — BUT the D6 register + /// also deploys the P256Account inside the UserOp (initCode), which the EntryPoint + /// runs within this limit (~1.3M on Base). So set it to cover the DEPLOY, not just + /// the verify (Base: 1.6M even with the precompile; the earlier 200k OOG'd the + /// deploy → AA13). `None` ⇒ the broker's 1.5M default (chains on the pure-Solidity + /// verifier). NEVER set it below the real cost: under-gas reverts inside + /// `validateUserOp` as a false `SIG_VALIDATION_FAILED` (#225 / gap #7). + /// Env `ACCEPT_VERIFICATION_GAS_LIMIT[_]` overrides it. #[serde(default, skip_serializing_if = "Option::is_none")] pub verification_gas_limit: Option, /// ERC-4337 UserOp `preVerificationGas` for master ops. `None` ⇒ the broker @@ -551,13 +560,13 @@ mod tests { #[test] fn base_gas_carries_precompile_verification_limit_heima_defaults() { - // #278 D6: Base has the live RIP-7212 precompile (#287), so its profile - // pins the cheap ~200k verificationGasLimit; Heima stays on the broker's - // 1.5M default (None here) until the register verify is profiled live - // with the precompile — lowering it blind would revert as a false - // SIG_VALIDATION_FAILED (#225). + // #278 D6: the register DEPLOYS the P256Account inside the UserOp (initCode), + // and the EntryPoint runs that ~1.3M deploy within verificationGasLimit — so + // even with Base's cheap RIP-7212 precompile verify (#287) the value must cover + // the DEPLOY (1.6M here; the earlier 200k OOG'd it → AA13). Heima stays on the + // broker's 1.5M default (None here). let base = ChainProfile::load_builtin("base").unwrap(); - assert_eq!(base.gas.verification_gas_limit, Some(200_000)); + assert_eq!(base.gas.verification_gas_limit, Some(1_600_000)); let heima = ChainProfile::load_builtin("heima").unwrap(); assert_eq!(heima.gas.verification_gas_limit, None); assert_eq!(heima.gas.pre_verification_gas, None); diff --git a/crates/agentkeys-daemon/src/ui_bridge.rs b/crates/agentkeys-daemon/src/ui_bridge.rs index 598081d8..7a076b92 100644 --- a/crates/agentkeys-daemon/src/ui_bridge.rs +++ b/crates/agentkeys-daemon/src/ui_bridge.rs @@ -861,7 +861,7 @@ pub fn build_state( // $AGENTKEYS_CHAIN, defaulting to heima mainnet. Drives /v1/chain/info + // /v1/audit/:id/decode (#153). Never hard-fails: an unknown chain name // falls back to the embedded heima profile. - let chain_profile = match agentkeys_core::chain_profile::ChainProfile::resolve( + let mut chain_profile = match agentkeys_core::chain_profile::ChainProfile::resolve( None, std::env::var("AGENTKEYS_CHAIN").ok().as_deref(), std::env::var("AGENTKEYS_CHAIN_PROFILE_FILE") @@ -881,6 +881,19 @@ pub fn build_state( agentkeys_core::chain_profile::ChainProfile::load_builtin("heima")? } }; + // The daemon was the ONLY component sourcing its RPC from the compiled chain profile; + // the broker (accept.rs/cap.rs), every worker (state.rs) and the bundler all read + // AGENTKEYS_CHAIN_RPC_HTTP from their env. Read the same var here (dev.sh sources it + // from operator-workstation..env, like setup-broker-host.sh does for the broker) + // so the daemon's chain reads AND /v1/chain/info (the top-right badge) match the rest + // of the system instead of diverging on the profile default. + if let Some(rpc) = chain_rpc_from_env(&chain_profile.name, |k| std::env::var(k).ok()) { + tracing::info!( + chain = %chain_profile.name, rpc = %rpc, + "ui-bridge: chain RPC from AGENTKEYS_CHAIN_RPC_HTTP[_] env (matches broker/workers/bundler)" + ); + chain_profile.rpc.http = rpc; + } Ok(Arc::new(UiBridgeState { webauthn, enroll: RwLock::new(EnrollState::default()), @@ -2514,14 +2527,25 @@ async fn master_register_submit( let (resp, parsed) = forward_to_broker_value(&broker, "/v1/register/submit", &j1, &body).await; if !resp.status().is_success() { - let detail = parsed - .map(|v| v.to_string()) - .unwrap_or_else(|| "broker /v1/register/submit failed".into()); - return Err(err( - StatusCode::BAD_GATEWAY, - detail, - "register-submit-failed", - )); + // Surface the broker's REAL reason — it emits a precise error (e.g. + // "handleOps did not broadcast: bundler eth_sendUserOperation: " + // or a decoded handleOps revert). The old generic fallback hid every + // Base register failure behind one opaque line. Forward the broker's + // status too, so a 409 already-registered / 503 sponsored-only doesn't + // masquerade as a 502 gateway error. + let status = resp.status(); + let detail = parsed.map(|v| v.to_string()).unwrap_or_else(|| { + "broker /v1/register/submit failed (broker returned an empty or non-JSON body)" + .into() + }); + tracing::warn!( + target: "agentkeys.daemon.ui_bridge", + account = %pending.account, + %status, + detail = %detail, + "#278 D6: master register submit FAILED at broker — UserOp not broadcast" + ); + return Err(err(status, detail, "register-submit-failed")); } let v = parsed.unwrap_or(serde_json::Value::Null); let (tx_hash, _) = submit_receipts(&v); @@ -3672,6 +3696,22 @@ fn profile_is_supported(p: &agentkeys_core::chain_profile::ChainProfile) -> bool !p.contracts.is_empty() && p.contract_set_version.is_some() } +/// The chain RPC the daemon should dial, read from `AGENTKEYS_CHAIN_RPC_HTTP[_]` +/// — the SAME env var the broker (`accept.rs`/`cap.rs`), every worker (`state.rs`) and +/// the bundler (`main.rs`) already read. The daemon was the ONE component that sourced +/// its RPC from the compiled chain profile instead, so a deployed env never reached it; +/// reading the same var here removes that divergence. `` is the upper-cased +/// profile name (`-`→`_`), with bare `AGENTKEYS_CHAIN_RPC_HTTP` as the fallback. Unset / +/// blank ⇒ `None` (caller keeps the profile default). Pure (lookup injected) so it's +/// testable without mutating process env (#258). +fn chain_rpc_from_env(chain_name: &str, lookup: impl Fn(&str) -> Option) -> Option { + let suffix = chain_name.to_uppercase().replace('-', "_"); + lookup(&format!("AGENTKEYS_CHAIN_RPC_HTTP_{suffix}")) + .or_else(|| lookup("AGENTKEYS_CHAIN_RPC_HTTP")) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + /// Resolve which profile `GET /v1/chain/info` serves: the daemon's /// operational profile by default, or any SUPPORTED built-in profile when /// the UI asks for a different view chain via `?chain=` (#282 web chain @@ -3736,6 +3776,31 @@ mod chain_view_tests { assert!(e.contains("heima")); } + #[test] + fn chain_rpc_from_env_matches_broker_precedence() { + use super::chain_rpc_from_env; + use std::collections::HashMap; + // chain-suffixed wins — the same var the broker/workers/bundler read. + let base: HashMap<&str, &str> = [( + "AGENTKEYS_CHAIN_RPC_HTTP_BASE", + "https://base-rpc.publicnode.com", + )] + .into(); + assert_eq!( + chain_rpc_from_env("base", |k| base.get(k).map(|v| v.to_string())), + Some("https://base-rpc.publicnode.com".into()) + ); + // bare var is the fallback when no chain-suffixed override is set. + let bare: HashMap<&str, &str> = [("AGENTKEYS_CHAIN_RPC_HTTP", "https://x")].into(); + assert_eq!( + chain_rpc_from_env("heima", |k| bare.get(k).map(|v| v.to_string())), + Some("https://x".into()) + ); + // unset ⇒ None (caller keeps the profile default); blank ⇒ None. + assert_eq!(chain_rpc_from_env("base", |_| None), None); + assert_eq!(chain_rpc_from_env("base", |_| Some(" ".into())), None); + } + #[test] fn builtin_without_a_deployed_registry_is_not_viewable() { // ethereum/sepolia/anvil ship as profiles but carry no AgentKeys @@ -4703,10 +4768,12 @@ async fn forward_to_broker_value( let st = StatusCode::from_u16(resp.status().as_u16()).unwrap_or(StatusCode::BAD_GATEWAY); let txt = resp.text().await.unwrap_or_default(); - let parsed = st - .is_success() - .then(|| serde_json::from_str::(&txt).ok()) - .flatten(); + // Parse the body on BOTH success and error. Success callers read the + // submit receipts from it; the #278 register-submit error path surfaces + // the broker's REAL reason from it (e.g. "handleOps did not broadcast: + // bundler eth_sendUserOperation: ") instead of a generic + // fallback — propagate the error through every layer, never swallow it. + let parsed = serde_json::from_str::(&txt).ok(); let response = ( st, [(axum::http::header::CONTENT_TYPE, "application/json")], diff --git a/crates/agentkeys-worker-creds/src/verify.rs b/crates/agentkeys-worker-creds/src/verify.rs index 549c82c2..dc1ff2f5 100644 --- a/crates/agentkeys-worker-creds/src/verify.rs +++ b/crates/agentkeys-worker-creds/src/verify.rs @@ -416,13 +416,16 @@ async fn eth_call( "params": [{"to": to, "data": data}, "latest"], "id": 1, }); - // The Heima public RPC intermittently 500s on eth_call (~12% per call, - // returning an HTML error page → non-JSON body). A single attempt makes - // every chain-verify a coin-flip and the worker returns a 502. Retry - // transient failures (transport error / HTTP 5xx / non-JSON body) with - // backoff; do NOT retry a valid JSON-RPC `error` (a real revert/bad-arg - // result, which is deterministic). - const ATTEMPTS: u32 = 4; + // Public RPCs fail eth_call transiently in two ways: Heima 500s ~12% of calls + // (HTML error page → non-JSON body), and Base's free endpoint THROTTLES the + // onboarding burst of reads with a JSON-RPC rate-limit error (-32016 "over rate + // limit"). A single attempt makes every chain-verify a coin-flip → a false 502 + // (the cap looks unverifiable when the chain is fine). Retry transient failures + // — transport error / HTTP 5xx / non-JSON body / a rate-limit JSON-RPC error — + // with backoff; do NOT retry a DETERMINISTIC JSON-RPC error (a real revert / + // bad-arg result). A dedicated (non-throttled) RPC is the systemic fix; this + // keeps a burst on a public endpoint from failing onboarding. + const ATTEMPTS: u32 = 5; let mut last = String::new(); for attempt in 0..ATTEMPTS { if attempt > 0 { @@ -448,6 +451,12 @@ async fn eth_call( } }; if let Some(err) = v.get("error") { + // A rate-limit error is TRANSIENT (public RPCs throttle bursts), unlike a + // revert — back off + retry it like a 5xx instead of failing the verify. + if is_rate_limit_error(err) { + last = format!("eth_call rate-limited: {err}"); + continue; + } return Err(VerifyError::ChainRpc(format!("rpc error: {err}"))); } return v @@ -461,6 +470,28 @@ async fn eth_call( ))) } +/// Is this JSON-RPC `error` a TRANSIENT rate-limit (retry) vs a deterministic +/// revert / bad-arg (terminal)? Covers the common public-RPC codes — Base's +/// `-32016` "over rate limit", the `-32005` / `-32029` "limit exceeded" family — +/// and any message mentioning rate limiting / too many requests. +fn is_rate_limit_error(err: &serde_json::Value) -> bool { + if matches!( + err.get("code").and_then(|c| c.as_i64()), + Some(-32016) | Some(-32005) | Some(-32029) + ) { + return true; + } + err.get("message") + .and_then(|m| m.as_str()) + .map(|m| m.to_lowercase()) + .map(|m| { + m.contains("rate limit") + || m.contains("too many requests") + || m.contains("limit exceeded") + }) + .unwrap_or(false) +} + fn parse_device_entry(raw: &str) -> Result { let hex = raw.trim_start_matches("0x"); // DeviceEntry post codex H1 (SidecarRegistry.sol) has 11 ABI words: @@ -969,4 +1000,28 @@ mod tests { Err(VerifyError::CapPopStale { .. }) )); } + + #[test] + fn is_rate_limit_error_retries_throttles_not_reverts() { + use serde_json::json; + // Transient throttles (Base -32016, the -32005/-32029 family, or a + // rate-limit message) ⇒ retry. + assert!(is_rate_limit_error( + &json!({"code": -32016, "message": "over rate limit"}) + )); + assert!(is_rate_limit_error( + &json!({"code": -32005, "message": "limit exceeded"}) + )); + assert!(is_rate_limit_error(&json!({"code": -32029}))); + assert!(is_rate_limit_error( + &json!({"message": "Too Many Requests"}) + )); + // A deterministic revert / bad-arg ⇒ terminal (must NOT retry). + assert!(!is_rate_limit_error( + &json!({"code": 3, "message": "execution reverted"}) + )); + assert!(!is_rate_limit_error( + &json!({"code": -32000, "message": "invalid opcode"}) + )); + } } diff --git a/docs/operator-runbook-base-onboarding.md b/docs/operator-runbook-base-onboarding.md new file mode 100644 index 00000000..54090ed1 --- /dev/null +++ b/docs/operator-runbook-base-onboarding.md @@ -0,0 +1,76 @@ +**Scope:** debugging Base master onboarding — register (`K11 enroll → master account assembled → REGISTER submit`) and config init (`Encrypt + store to Config`). Use when an onboarding step 502s or shows "failed". Companion to PR #311 (chain id, register deploy gas, worker RPC retry). + +All commands are READ-ONLY. Contract addresses + the RPC resolve from the chain profile — never hardcode them ([`scripts/check-deployed-contracts-sync.sh`](../scripts/check-deployed-contracts-sync.sh) gates that). + +## 0. First look — the browser console (no host) + +The daemon surfaces the broker's REAL reason (PR #311), not a generic fallback. In the failing step's `debug.ts` log, expand the `{detail, reason}` object — the `reason` + the nested broker `error` body classify it via §1. (If you still see the opaque `broker /v1/register/submit failed`, the local daemon binary is stale — rebuild via `dev.sh`.) + +## 1. Error → cause → fix + +| Browser detail / reason | Layer | Cause | Fix | +|---|---|---|---| +| `register/submit 502: handleOps did not broadcast: bundler eth_sendUserOperation: …` (submitter nonce stays 0, no EntryPoint event) | bundler | signed `handleOps` for the wrong chain id | PR #311 `44035d4`; confirm bundler `eth_chainId` (§2) | +| `register/submit 502: … AA13 initCode failed or OOG` (tx lands + reverts; `gasUsed ≪ gasLimit`) | EntryPoint | register's in-UserOp account deploy (~1.3M) OOG'd `verificationGasLimit` | PR #311 `fcccbf1` (base.json `verification_gas_limit` 1.6M) | +| `… AA31 … paymaster deposit too low` | EntryPoint | prefund reserve (Σgas × maxFee) > paymaster deposit | PR #311 `fcccbf1` (base.json `max_fee_gwei` 2); or top up the deposit (§3) | +| `config/init 502: … chain RPC error … -32016 "over rate limit" …`, reason `chain_rpc` | chain read (worker / broker / bundler) | public RPC (`mainnet.base.org`) throttles the broker's AWS IP (~75% from the host) | point the host at a higher-headroom RPC via the `AGENTKEYS_CHAIN_RPC_HTTP_BASE` env var (§ Chain RPC) + redeploy. The worker retry (`ba9901f`) is only a burst backstop; broker/bundler reads have no retry, so the RPC switch — not the retry — is the real fix | +| `config/init 403 device_not_active` | worker chain-verify | master not registered on chain — an UPSTREAM register failure (a row above), not a config bug | fix the register failure first, then retry | + +## 2. Host logs (`ssh-broker.sh base`) + +```bash +# the submit-relay reason — the tracing PR #311 added to the broker: +ssh-broker.sh base 'sudo journalctl -u agentkeys-broker --since "10 min ago" --no-pager \ + | grep -iE "submit relay|handleOps|AA[0-9][0-9]|did not broadcast|chain_rpc|-32016|over rate" | tail -20' + +# bundler chain id (0x2105 = Base 8453 OK; 0x33c2d = Heima 212013 = MISCONFIG) + recent broadcasts: +ssh-broker.sh base 'systemctl is-active agentkeys-bundler; curl -s 127.0.0.1:9098 -H "content-type: application/json" \ + -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_chainId\",\"params\":[]}"; echo' +ssh-broker.sh base 'sudo journalctl -u agentkeys-bundler --since "10 min ago" --no-pager | grep -iE "broadcast|error" | tail' + +# the worker that failed (config/cred/memory) — its chain-verify error (-32016 etc.): +ssh-broker.sh base 'sudo journalctl -u agentkeys-worker-config --since "10 min ago" --no-pager | tail -20' + +# nginx: did the request arrive + upstream duration? (fast 502 = broker returned it; ~90s = receipt-poll timeout) +ssh-broker.sh base 'sudo grep -hE "register/submit|accept/submit|config/init|/cap/" /var/log/nginx/*access*.log | tail -10' +``` + +## 3. On-chain forensics (anywhere — read-only) + +```bash +cd "$AGENTKEYS_REPO"; P=crates/agentkeys-core/chain-profiles/base.json +RPC=$(jq -r '.rpc.http' "$P") # or any Base RPC +EP=$(jq -r '.contracts[]|select(.name=="EntryPoint").address' "$P") +PM=$(jq -r '.contracts[]|select(.name=="VerifyingPaymaster").address' "$P") +rpc(){ curl -s -X POST "$RPC" -H 'content-type: application/json' -d "$1"; } + +ACCT= # the master account from "master account assembled" +TX= # from the bundler log / browser, if one landed + +# master account deployed? (0x / 0 bytes ⇒ register never landed) +rpc "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getCode\",\"params\":[\"$ACCT\",\"latest\"]}" + +# sponsor/submitter (paymaster brokerSigner, selector 0xfca82211) — funded? nonce 0 ⇒ never broadcast +SIGNER=0x$(rpc "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\",\"params\":[{\"to\":\"$PM\",\"data\":\"0xfca82211\"},\"latest\"]}" | jq -r .result | tail -c 41) +rpc "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getBalance\",\"params\":[\"$SIGNER\",\"latest\"]}" +rpc "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getTransactionCount\",\"params\":[\"$SIGNER\",\"latest\"]}" + +# paymaster EntryPoint deposit (balanceOf, selector 0x70a08231) — AA31 if the op prefund exceeds it +rpc "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\",\"params\":[{\"to\":\"$EP\",\"data\":\"0x70a08231000000000000000000000000${PM:2}\"},\"latest\"]}" + +# a reverted handleOps tx: gasUsed ≪ gasLimit ⇒ inner OOG/revert (AA13), not an outer OOG +rpc "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getTransactionReceipt\",\"params\":[\"$TX\"]}" \ + | jq '{status:.result.status, gasUsed:.result.gasUsed, to:.result.to}' +``` + +For an `AA13`: decode the UserOp's `initCode` from the reverted tx's `handleOps` calldata and `eth_estimateGas` the `factory.createAccount` call — compare the cost to the op's `verificationGasLimit` (the high 16 bytes of `accountGasLimits`). If `verificationGasLimit < deploy cost` ⇒ AA13 OOG. + +## Redeploy +`bash scripts/setup-broker-host.sh --base --ref main` on the Base host rebuilds bundler + broker + workers and recompiles `base.json`. No chain/cloud changes; no paymaster re-funding for the PR #311 gas values. + +## Chain RPC — env var, not a flag +The broker host's RPC is the env var **`AGENTKEYS_CHAIN_RPC_HTTP_BASE`** in [`scripts/operator-workstation.base.env`](../scripts/operator-workstation.base.env) (set to PublicNode because the broker's AWS IP is throttled by the profile default `mainnet.base.org`). Resolution precedence in `setup-broker-host.sh`: **`AGENTKEYS_CHAIN_RPC_HTTP_` env var > chain profile `rpc.http` > the host's worker env**. To change the RPC: edit that env var and redeploy — it's an env var, so no recompile (the profile is `include_str!`-compiled in). Verify it took: +```bash +ssh-broker.sh base 'sudo grep -rhE "AGENTKEYS_CHAIN_RPC_HTTP" /etc/agentkeys/*.env | sort -u' +``` +Gotcha: a `base.json` `rpc.http` change ALONE does **not** propagate to an already-deployed host — the worker env was read first and pinned the stale value (fixed: the worker env is now read last). The env var is the operator knob; the `--chain-rpc` flag was removed. diff --git a/scripts/operator-workstation.base.env b/scripts/operator-workstation.base.env index a4c99ce2..7c4cf304 100644 --- a/scripts/operator-workstation.base.env +++ b/scripts/operator-workstation.base.env @@ -99,6 +99,19 @@ BROKER_EMAIL_FROM_ADDRESS=noreply-base@${MAIL_DOMAIN} # derives the host's RPC default from it. AGENTKEYS_CHAIN=base +# Chain RPC EVERY component dials — broker, all workers, bundler AND the local daemon +# (dev.sh sources this file). The chain profile's rpc.http (https://mainnet.base.org) is +# the canonical default, but this PROD broker's AWS egress IP is hard-throttled by it +# (measured ~75% of concurrent eth_calls return -32016 "over rate limit" from the host, +# vs 0% on PublicNode), so we point everything at the keyless, higher-headroom PublicNode +# endpoint. Read in two places with the SAME precedence (AGENTKEYS_CHAIN_RPC_HTTP_ > +# chain profile rpc.http): setup-broker-host.sh writes it into each broker/worker/bundler +# unit env, and the daemon's ui_bridge reads it via chain_rpc_from_env (so the daemon's +# chain reads + the web app's chain badge match the broker, not just the profile default). +# Changeable without a rebuild (the profile is include_str!-compiled; this env var is not). +# Clear it to fall back to the profile default. +AGENTKEYS_CHAIN_RPC_HTTP_BASE=https://base-rpc.publicnode.com + # Base contract addresses (*_BASE family, read via the env_profile suffix # convention) — INTENTIONALLY EMPTY until #282 Phase 4 deploys the contract # set with the Base deployer. The broker/bundler/workers boot DEGRADED with diff --git a/scripts/setup-broker-host.sh b/scripts/setup-broker-host.sh index 9501f740..6f4c7570 100755 --- a/scripts/setup-broker-host.sh +++ b/scripts/setup-broker-host.sh @@ -74,9 +74,10 @@ CONFIG_HOST="" # --config-host: hostname for config-service worker CLASSIFY_HOST="" # --classify-host: hostname for classifier-service worker (default classify.) — #207 items 2-3 compute gate # Chain + bucket overrides for the credentials + memory + config workers. # Defaults target Heima Mainnet (production chain) with addresses pulled from -# scripts/operator-workstation.env. Pass --chain-rpc / --vault-bucket / -# --memory-bucket / --config-bucket / --scope-addr / --registry-addr / -# --k3-counter-addr to override per-host (e.g. when running against a fork or testnet). +# scripts/operator-workstation.env. Pass --vault-bucket / --memory-bucket / +# --config-bucket / --scope-addr / --registry-addr / --k3-counter-addr to override +# per-host. The chain RPC is NOT a flag: set AGENTKEYS_CHAIN_RPC_HTTP_ in the +# sourced operator-workstation..env (env-var > chain profile rpc.http > worker env). CHAIN_RPC="" VAULT_BUCKET="" MEMORY_BUCKET="" @@ -133,7 +134,6 @@ while (( $# > 0 )); do --memory-host) MEMORY_HOST="$2"; shift 2 ;; --config-host) CONFIG_HOST="$2"; shift 2 ;; --classify-host) CLASSIFY_HOST="$2"; shift 2 ;; - --chain-rpc) CHAIN_RPC="$2"; shift 2 ;; --vault-bucket) VAULT_BUCKET="$2"; shift 2 ;; --memory-bucket) MEMORY_BUCKET="$2"; shift 2 ;; --config-bucket) CONFIG_BUCKET="$2"; shift 2 ;; @@ -370,18 +370,18 @@ if [[ -f "$EXISTING_UNIT" ]]; then log " detected: ISSUER_URL=${ISSUER_URL:-(unset)} ACCOUNT_ID=${ACCOUNT_ID:-(unset)} REGION=$REGION CRED_MODE=$CRED_MODE" fi -# Detect previously-configured worker overrides. Keeps re-runs idempotent: -# operator who passed `--chain-rpc https://devnet.example` on a first run -# can re-run with no flags and the worker env files keep their first-run -# values instead of resetting to the hardcoded defaults. +# Detect previously-configured worker BUCKET overrides. Keeps re-runs idempotent: +# the worker env files keep their first-run bucket values instead of resetting to +# defaults. (The chain RPC is deliberately NOT read here — it resolves from +# AGENTKEYS_CHAIN_RPC_HTTP_ / the chain profile below, AFTER the operator env is +# sourced, so a profile or env change propagates on redeploy instead of being pinned +# to the stale worker env — the bug that kept the host on a throttled RPC across +# redeploys despite the profile/env changing.) read_envfile_var() { local env_file="$1" key="$2" sudo test -f "$env_file" || return 0 sudo grep -E "^${key}=" "$env_file" 2>/dev/null | head -1 | sed -E "s/^${key}=//" || true } -if [[ -z "$CHAIN_RPC" ]]; then - CHAIN_RPC="$(read_envfile_var /etc/agentkeys/worker-creds.env AGENTKEYS_CHAIN_RPC_HTTP)" -fi if [[ -z "$VAULT_BUCKET" ]]; then VAULT_BUCKET="$(read_envfile_var /etc/agentkeys/worker-creds.env VAULT_BUCKET)" fi @@ -664,15 +664,26 @@ else AGENTKEYS_CHAIN="${AGENTKEYS_CHAIN:-heima}" fi CHAIN_ENV_SUFFIX="$(printf '%s' "$AGENTKEYS_CHAIN" | tr '[:lower:]' '[:upper:]' | tr '-' '_')" -# RPC default from the chain profile JSON (the #251 machine source of truth); -# the heima literal stays as the last-resort fallback for hosts without jq. +# Chain RPC precedence (NO --chain-rpc flag; explicit env var > profile > worker env): +# 1. AGENTKEYS_CHAIN_RPC_HTTP_ — operator override from the sourced +# operator-workstation..env (e.g. AGENTKEYS_CHAIN_RPC_HTTP_BASE when the +# broker IP is throttled by the profile's default public RPC). No rebuild needed. +# 2. the chain profile's rpc.http — the #251 source-of-truth default. +# 3. the host's worker env — last resort (host with no sourced env file). +# 4. the heima literal — final fallback (no jq). +# The worker env is read LAST, not first: reading it first pinned the host to a stale +# RPC across redeploys, so a profile/env change could not propagate. if [[ -z "$CHAIN_RPC" ]]; then + _rpc_override="AGENTKEYS_CHAIN_RPC_HTTP_${CHAIN_ENV_SUFFIX}" + CHAIN_RPC="${!_rpc_override:-}" + unset _rpc_override +fi +if [[ -z "$CHAIN_RPC" ]] && have jq; then _profile_json="$REPO_ROOT/crates/agentkeys-core/chain-profiles/${AGENTKEYS_CHAIN}.json" - if [[ -f "$_profile_json" ]] && have jq; then - CHAIN_RPC="$(jq -r '.rpc.http // empty' "$_profile_json")" - fi + [[ -f "$_profile_json" ]] && CHAIN_RPC="$(jq -r '.rpc.http // empty' "$_profile_json")" unset _profile_json fi +[[ -z "$CHAIN_RPC" ]] && CHAIN_RPC="$(read_envfile_var /etc/agentkeys/worker-creds.env AGENTKEYS_CHAIN_RPC_HTTP)" [[ -z "$CHAIN_RPC" ]] && CHAIN_RPC="https://rpc.heima-parachain.heima.network" # Per-chain address env reads (indirect expansion on the chain suffix — @@ -1667,6 +1678,15 @@ done BUNDLER_EP_LINE="# (no EntryPoint in this host's env — bundler boots degraded/unsponsored)" [ -n "$ENTRYPOINT_ADDR" ] && BUNDLER_EP_LINE="Environment=ENTRYPOINT_ADDRESS_${CHAIN_ENV_SUFFIX}=$ENTRYPOINT_ADDR" +# DELIBERATELY no `Environment=AGENTKEYS_CHAIN_ID_*` line. The bundler resolves its +# chain id from the compiled-in chain profile keyed by AGENTKEYS_CHAIN (the SAME +# source of truth the broker uses — see agentkeys-bundler::chain_id_from_chain_profile). +# Injecting it here would DUPLICATE the chain profile's chain_id (drift risk) and is +# precisely what hid the Base bug: the unit set AGENTKEYS_CHAIN=base but no chain id, +# so the bundler fell back to a hardcoded Heima 212013 and signed handleOps for the +# wrong chain → Base never broadcast. A profileless chain (local anvil) sets +# AGENTKEYS_CHAIN_ID in the env explicitly; deployed stacks (heima/base) need nothing. + log "Writing agentkeys-bundler.service" sudo tee /etc/systemd/system/agentkeys-bundler.service >/dev/null <