diff --git a/Cargo.lock b/Cargo.lock
index 27f0cf0b..9a8052b6 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/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));
diff --git a/apps/parent-control/app/_components/pairing.tsx b/apps/parent-control/app/_components/pairing.tsx
index dcdfd32c..cb6dd706 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}
+
device public address · 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-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/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..540da6f4
--- /dev/null
+++ b/crates/agentkeys-cli/src/cred_admin.rs
@@ -0,0 +1,120 @@
+//! 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, CredStoreInput,
+};
+
+/// 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")
+}
+
+/// 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/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..ecef8689 100644
--- a/crates/agentkeys-cli/src/main.rs
+++ b/crates/agentkeys-cli/src/main.rs
@@ -392,6 +392,69 @@ 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,
+ },
+ /// 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)]
@@ -1389,6 +1452,72 @@ 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
+ }
+ 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),
+ }
+ }
+ },
};
match result {
diff --git a/crates/agentkeys-daemon/src/main.rs b/crates/agentkeys-daemon/src/main.rs
index 53e9972e..a9ad10f6 100644
--- a/crates/agentkeys-daemon/src/main.rs
+++ b/crates/agentkeys-daemon/src/main.rs
@@ -654,12 +654,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 →
@@ -775,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
@@ -806,9 +815,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/crates/agentkeys-daemon/src/ui_bridge.rs b/crates/agentkeys-daemon/src/ui_bridge.rs
index 34b67955..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,
@@ -2129,14 +2138,32 @@ 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 {
@@ -3764,9 +3791,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| {
@@ -3834,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);
@@ -3841,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");
diff --git a/docs/operator-runbook-harness.md b/docs/operator-runbook-harness.md
index d54fb39b..ad456765 100644
--- a/docs/operator-runbook-harness.md
+++ b/docs/operator-runbook-harness.md
@@ -196,6 +196,9 @@ 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`.
+- **`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 0c49a87c..be602941 100644
--- a/docs/operator-runbook-wire.md
+++ b/docs/operator-runbook-wire.md
@@ -1,32 +1,189 @@
-# 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.
+
+## Two independent paths — pick one
+
+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 · 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, in the sandbox run
+`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
+
+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)
+
+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 — 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** (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
+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`.
+
+**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; 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
+ # → {"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)")"
+ ```
+
+**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)
+
+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 +194,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 +307,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 +368,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 +449,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 +506,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 +542,12 @@ 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
+- **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
- [`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.
diff --git a/harness/CLAUDE.md b/harness/CLAUDE.md
index c1aff182..caf9923d 100644
--- a/harness/CLAUDE.md
+++ b/harness/CLAUDE.md
@@ -217,6 +217,9 @@ 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` |
+| `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/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
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
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.
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"