diff --git a/.gitignore b/.gitignore index 40656fa..5ec43f5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,17 @@ .omc .obsidian /docs/test-screenshots/ +.gstack/ +AWSCLIV2.pkg + +# Local developer secrets — template is checked in as .env.example. +agentkeys-secrets.env + +# agentkeys-workflow-collection: per-run recordings (~50MB each, binary +# trace.zips don't delta-compress). Keep locally; commit only curated +# reference recordings via explicit negations below. +provisioner-scripts/recordings/*/ +!provisioner-scripts/recordings/openrouter-signup-reference/ +!provisioner-scripts/recordings/brave-search-signup-reference/ +!provisioner-scripts/recordings/elevenlabs-signup-reference/ +!provisioner-scripts/recordings/.gitkeep diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..6246632 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,13 @@ +{ + "mcpServers": { + "chrome-devtools": { + "type": "stdio", + "command": "npx", + "args": [ + "chrome-devtools-mcp@latest", + "--browser-url=http://127.0.0.1:9222" + ], + "env": {} + } + } +} \ No newline at end of file diff --git a/agentkeys-secrets.env.example b/agentkeys-secrets.env.example new file mode 100644 index 0000000..8e89c8f --- /dev/null +++ b/agentkeys-secrets.env.example @@ -0,0 +1,64 @@ +# agentkeys-secrets.env.example +# +# Template for local developer secrets. DO NOT commit the real file — that's +# gitignored as `agentkeys-secrets.env`. Two ways to use: +# +# 1. Source it manually per shell: +# cp agentkeys-secrets.env.example agentkeys-secrets.env +# +# source agentkeys-secrets.env +# +# 2. Source it from ~/.zshenv so non-interactive shells (Claude Code's Bash +# tool, cron jobs) pick it up too: +# echo "[ -f $PWD/agentkeys-secrets.env ] && source $PWD/agentkeys-secrets.env" >> ~/.zshenv +# +# After filling, run: `source scripts/stage6-demo-env.sh` to mint 1 h STS +# temp creds from DAEMON_* and export them as AWS_*. + +# ─── Long-lived IAM users (rotate quarterly) ────────────────────────────────── + +# Daemon user — only permission is `sts:AssumeRole` into agentkeys-agent. +# Compromise blast radius = can assume the role; rotate via `aws iam +# update-access-key --status Inactive` + create new key. +export DAEMON_ACCESS_KEY_ID=AKIA...REPLACE_ME +export DAEMON_SECRET_ACCESS_KEY=REPLACE_ME + +# Admin user — used for infra changes (SES config, IAM policies). NOT used by +# the scraper/recorder runtime. If you don't do admin work, leave blank. +export ADMIN_AWS_ACCESS_KEY_ID=AKIA...REPLACE_ME_OR_BLANK +export ADMIN_AWS_ACCESS_KEY_SECRET=REPLACE_ME_OR_BLANK + +# ─── Non-secret infrastructure knobs ────────────────────────────────────────── + +export REGION=us-east-1 +export DOMAIN=bots.litentry.org +export ACCOUNT_ID=429071895007 +export BUCKET="agentkeys-mail-${ACCOUNT_ID}" +export ROLE_ARN="arn:aws:iam::${ACCOUNT_ID}:role/agentkeys-agent" +export DAEMON_USER_ARN="arn:aws:iam::${ACCOUNT_ID}:user/agentkeys-daemon" +export PARENT_ZONE_ID=Z09723983CFJOHAE3VC65 # litentry.org Route 53 zone + +# Bucket where SES drops inbound mail for bots.litentry.org addresses. +export AGENTKEYS_SES_BUCKET="$BUCKET" +export AGENTKEYS_EMAIL_BACKEND=ses-s3 + +# Chrome CDP endpoint the recorder connects to. +export CDP_URL=http://localhost:9222 + +# ─── Signup / login test credentials ────────────────────────────────────────── + +# Stable password for throwaway signup accounts. Fresh email per run is auto- +# generated by the recorder (bot-${Date.now()}@bots.litentry.org). +export AGENTKEYS_SIGNUP_PASSWORD=REPLACE_ME_WITH_STRONG_PASSWORD + +# ─── CAPTCHA-solving service (optional) ─────────────────────────────────────── +# +# CapSolver handles hCaptcha / reCAPTCHA / Cloudflare Turnstile on services +# that gate signup behind a challenge (ElevenLabs uses invisible hCaptcha). +# Without this key, the recorder escalates to human-in-loop on those +# services. Brave Search's custom PoW captcha is NOT a CapSolver task — +# it solves client-side on its own. +# +# Pricing: ~$1 per 1000 hCaptcha solves. +# Sign up: https://capsolver.com (paste the CAP-... token) +export CAPSOLVER_API_KEY=CAP-REPLACE_ME diff --git a/crates/agentkeys-cli/src/lib.rs b/crates/agentkeys-cli/src/lib.rs index 75785ff..622d0c3 100644 --- a/crates/agentkeys-cli/src/lib.rs +++ b/crates/agentkeys-cli/src/lib.rs @@ -909,6 +909,42 @@ pub async fn cmd_provision( } } +pub async fn cmd_inbox_provision(ctx: &CommandContext, agent: Option<&str>) -> Result { + let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; + let backend = ctx.backend(); + let agent_id = resolve_agent(&backend, &session, agent).await?; + + if ctx.verbose { + eprintln!("[verbose] POST {}/mock/inbox/provision", ctx.backend_url); + eprintln!("[verbose] agent: {}", agent_id.0); + } + + let address = backend + .provision_inbox(&session, &agent_id) + .await + .map_err(wrap_backend_error)?; + + Ok(address.to_string()) +} + +pub async fn cmd_inbox_list(ctx: &CommandContext, agent: Option<&str>) -> Result { + let session = ctx.load_session().context("load session (run `agentkeys init` first)")?; + let backend = ctx.backend(); + let agent_id = resolve_agent(&backend, &session, agent).await?; + + if ctx.verbose { + eprintln!("[verbose] GET {}/mock/inbox/list", ctx.backend_url); + eprintln!("[verbose] agent: {}", agent_id.0); + } + + let addresses = backend + .list_inboxes(&session, &agent_id) + .await + .map_err(wrap_backend_error)?; + + Ok(addresses.iter().map(|a| a.to_string()).collect::>().join("\n")) +} + pub fn cmd_feedback() -> String { let url = "https://github.com/agentkeys/agentkeys/discussions"; let opened = std::process::Command::new("open").arg(url).status().is_ok() diff --git a/crates/agentkeys-cli/src/main.rs b/crates/agentkeys-cli/src/main.rs index 3940d2e..dcf2383 100644 --- a/crates/agentkeys-cli/src/main.rs +++ b/crates/agentkeys-cli/src/main.rs @@ -1,6 +1,7 @@ use agentkeys_cli::{ - cmd_approve, cmd_feedback, cmd_init, cmd_link, cmd_provision, cmd_read, cmd_recover, - cmd_revoke, cmd_run, cmd_scope, cmd_store, cmd_teardown, cmd_usage, CommandContext, + cmd_approve, cmd_feedback, cmd_inbox_list, cmd_inbox_provision, cmd_init, cmd_link, + cmd_provision, cmd_read, cmd_recover, cmd_revoke, cmd_run, cmd_scope, cmd_store, cmd_teardown, + cmd_usage, CommandContext, }; @@ -172,6 +173,36 @@ enum Commands { long_about = "Open https://github.com/agentkeys/agentkeys/discussions in the default browser.\n\nExamples:\n agentkeys feedback" )] Feedback, + + #[command( + about = "Manage agent inbox addresses", + long_about = "Provision or list inbox addresses for an agent.\n\nOmit --agent to default to the session wallet.\n\nExamples:\n agentkeys inbox provision\n agentkeys inbox provision --agent 0xAGENT\n agentkeys inbox list\n agentkeys inbox list --agent 0xAGENT" + )] + Inbox { + #[command(subcommand)] + action: InboxAction, + }, +} + +#[derive(Subcommand)] +enum InboxAction { + #[command( + about = "Provision a new inbox address for an agent", + long_about = "Provision a new inbox email address for an agent and print the address.\n\nOmit --agent to default to the session wallet.\n\nExamples:\n agentkeys inbox provision\n agentkeys inbox provision --agent 0xAGENT" + )] + Provision { + #[arg(long, help = "Agent wallet address, alias, or email (defaults to session wallet)")] + agent: Option, + }, + + #[command( + about = "List inbox addresses provisioned for an agent", + long_about = "List all inbox email addresses provisioned for an agent, one per line.\n\nOmit --agent to default to the session wallet.\n\nExamples:\n agentkeys inbox list\n agentkeys inbox list --agent 0xAGENT" + )] + List { + #[arg(long, help = "Agent wallet address, alias, or email (defaults to session wallet)")] + agent: Option, + }, } #[tokio::main] @@ -208,6 +239,14 @@ async fn main() { }) } Commands::Feedback => Ok(cmd_feedback()), + Commands::Inbox { action } => match action { + InboxAction::Provision { agent } => { + cmd_inbox_provision(&ctx, agent.as_deref()).await + } + InboxAction::List { agent } => { + cmd_inbox_list(&ctx, agent.as_deref()).await + } + }, }; match result { diff --git a/crates/agentkeys-cli/tests/cli_tests.rs b/crates/agentkeys-cli/tests/cli_tests.rs index 891d150..9f12d57 100644 --- a/crates/agentkeys-cli/tests/cli_tests.rs +++ b/crates/agentkeys-cli/tests/cli_tests.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use agentkeys_cli::{ - cmd_init, cmd_link, cmd_provision, cmd_read, cmd_revoke, cmd_run, cmd_scope, cmd_store, - cmd_teardown, cmd_usage, CommandContext, + cmd_inbox_list, cmd_inbox_provision, cmd_init, cmd_link, cmd_provision, cmd_read, cmd_revoke, + cmd_run, cmd_scope, cmd_store, cmd_teardown, cmd_usage, CommandContext, }; use agentkeys_core::backend::CredentialBackend; use agentkeys_core::session_store::SessionStore; @@ -1059,6 +1059,8 @@ impl CredentialBackend for ProvisionTestBackend { async fn resolve_identity(&self, _: &Session, _: &str) -> Result { unimplemented!() } async fn get_scope(&self, _: &Session, _: &agentkeys_types::WalletAddress) -> Result, agentkeys_core::backend::BackendError> { unimplemented!() } async fn update_scope(&self, _: &Session, _: &agentkeys_types::WalletAddress, _: &agentkeys_types::Scope) -> Result<(), agentkeys_core::backend::BackendError> { unimplemented!() } + async fn provision_inbox(&self, _: &Session, _: &agentkeys_types::WalletAddress) -> Result { unimplemented!() } + async fn list_inboxes(&self, _: &Session, _: &agentkeys_types::WalletAddress) -> Result, agentkeys_core::backend::BackendError> { unimplemented!() } } // Test: provision masked output — subprocess emits a success key; stdout must be masked @@ -1269,3 +1271,48 @@ async fn cmd_scope_add_remove_overlap_errors() { "unexpected error: {err}" ); } + +#[tokio::test] +async fn inbox_provision_returns_address() { + let backend = create_test_backend(); + let (store, _tmp) = test_store(); + let (_wallet, session) = init_session_with_store(&backend, &store).await; + let ctx = ctx_with_session(backend, session, store); + + let result = cmd_inbox_provision(&ctx, None).await.unwrap(); + assert!( + result.starts_with("bot-") && result.contains('@'), + "expected bot-*@domain address, got: {result}" + ); +} + +#[tokio::test] +async fn inbox_list_after_provision_returns_one_entry() { + let backend = create_test_backend(); + let (store, _tmp) = test_store(); + let (_wallet, session) = init_session_with_store(&backend, &store).await; + let ctx = ctx_with_session(backend, session, store); + + let provisioned = cmd_inbox_provision(&ctx, None).await.unwrap(); + let listed = cmd_inbox_list(&ctx, None).await.unwrap(); + + let lines: Vec<&str> = listed.lines().collect(); + assert_eq!(lines.len(), 1, "expected 1 inbox, got: {listed}"); + assert_eq!(lines[0], provisioned.trim(), "listed address does not match provisioned"); +} + +#[tokio::test] +async fn inbox_list_accumulates_multiple_provisions() { + let backend = create_test_backend(); + let (store, _tmp) = test_store(); + let (_wallet, session) = init_session_with_store(&backend, &store).await; + let ctx = ctx_with_session(backend, session, store); + + cmd_inbox_provision(&ctx, None).await.unwrap(); + cmd_inbox_provision(&ctx, None).await.unwrap(); + cmd_inbox_provision(&ctx, None).await.unwrap(); + + let listed = cmd_inbox_list(&ctx, None).await.unwrap(); + let lines: Vec<&str> = listed.lines().collect(); + assert_eq!(lines.len(), 3, "expected 3 inboxes, got: {listed}"); +} diff --git a/crates/agentkeys-core/src/auth_request.rs b/crates/agentkeys-core/src/auth_request.rs index 1dc9964..39ad2a1 100644 --- a/crates/agentkeys-core/src/auth_request.rs +++ b/crates/agentkeys-core/src/auth_request.rs @@ -65,7 +65,7 @@ fn cbor_key_bytes(key: &Value) -> Vec { buf } -fn sort_map(map: &mut Vec<(Value, Value)>) { +fn sort_map(map: &mut [(Value, Value)]) { map.sort_by(|(a, _), (b, _)| { let a_bytes = cbor_key_bytes(a); let b_bytes = cbor_key_bytes(b); diff --git a/crates/agentkeys-core/src/backend.rs b/crates/agentkeys-core/src/backend.rs index 8196035..3381bfc 100644 --- a/crates/agentkeys-core/src/backend.rs +++ b/crates/agentkeys-core/src/backend.rs @@ -1,7 +1,7 @@ use agentkeys_types::{ AuditEvent, AuditFilter, AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, - EncryptedPairPayload, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, - Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, + EncryptedPairPayload, InboxAddress, OpenedAuthRequest, PairCode, PairPayload, PublicKey, + RegistrationToken, Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, }; use async_trait::async_trait; use thiserror::Error; @@ -155,6 +155,18 @@ pub trait CredentialBackend: Send + Sync { target_wallet: &WalletAddress, new_scope: &Scope, ) -> Result<(), BackendError>; + + async fn provision_inbox( + &self, + session: &Session, + agent_id: &WalletAddress, + ) -> Result; + + async fn list_inboxes( + &self, + session: &Session, + agent_id: &WalletAddress, + ) -> Result, BackendError>; } #[cfg(test)] @@ -333,6 +345,22 @@ mod tests { ) -> Result<(), BackendError> { unimplemented!() } + + async fn provision_inbox( + &self, + _session: &Session, + _agent_id: &WalletAddress, + ) -> Result { + unimplemented!() + } + + async fn list_inboxes( + &self, + _session: &Session, + _agent_id: &WalletAddress, + ) -> Result, BackendError> { + unimplemented!() + } } #[test] diff --git a/crates/agentkeys-core/src/mock_client.rs b/crates/agentkeys-core/src/mock_client.rs index 5f903b5..bb8d7aa 100644 --- a/crates/agentkeys-core/src/mock_client.rs +++ b/crates/agentkeys-core/src/mock_client.rs @@ -4,8 +4,8 @@ use serde_json::{json, Value}; use crate::backend::{BackendError, CredentialBackend}; use agentkeys_types::{ AuditEvent, AuditFilter, AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, - EncryptedPairPayload, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, - Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, + EncryptedPairPayload, InboxAddress, OpenedAuthRequest, PairCode, PairPayload, PublicKey, + RegistrationToken, Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, }; pub struct MockHttpClient { @@ -751,6 +751,60 @@ impl CredentialBackend for MockHttpClient { Ok(()) } + async fn provision_inbox( + &self, + session: &Session, + agent_id: &WalletAddress, + ) -> Result { + let resp = self + .client + .post(self.url("/mock/inbox/provision")) + .header("authorization", format!("Bearer {}", session.token)) + .json(&serde_json::json!({ "agent_id": agent_id.0 })) + .send() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + + if !resp.status().is_success() { + return Err(Self::map_error(resp).await); + } + + let body: Value = resp.json().await.map_err(|e| BackendError::Transport(e.to_string()))?; + let address = body["address"] + .as_str() + .ok_or_else(|| BackendError::Internal("missing address".into()))? + .to_string(); + Ok(InboxAddress(address)) + } + + async fn list_inboxes( + &self, + session: &Session, + agent_id: &WalletAddress, + ) -> Result, BackendError> { + let resp = self + .client + .get(self.url("/mock/inbox/list")) + .query(&[("agent_id", &agent_id.0)]) + .header("authorization", format!("Bearer {}", session.token)) + .send() + .await + .map_err(|e| BackendError::Transport(e.to_string()))?; + + if !resp.status().is_success() { + return Err(Self::map_error(resp).await); + } + + let body: Value = resp.json().await.map_err(|e| BackendError::Transport(e.to_string()))?; + let addresses = body + .as_array() + .ok_or_else(|| BackendError::Internal("expected array".into()))? + .iter() + .filter_map(|v| v.as_str().map(|s| InboxAddress(s.to_string()))) + .collect(); + Ok(addresses) + } + async fn recover_session( &self, identity: &agentkeys_types::AgentIdentity, diff --git a/crates/agentkeys-mcp/src/lib.rs b/crates/agentkeys-mcp/src/lib.rs index 53ba696..3b8143f 100644 --- a/crates/agentkeys-mcp/src/lib.rs +++ b/crates/agentkeys-mcp/src/lib.rs @@ -345,6 +345,8 @@ mod tests { async fn resolve_identity(&self, _: &Session, _: &str) -> Result { unimplemented!() } async fn get_scope(&self, _: &Session, _: &WalletAddress) -> Result, BackendError> { unimplemented!() } async fn update_scope(&self, _: &Session, _: &WalletAddress, _: &Scope) -> Result<(), BackendError> { unimplemented!() } + async fn provision_inbox(&self, _: &Session, _: &WalletAddress) -> Result { unimplemented!() } + async fn list_inboxes(&self, _: &Session, _: &WalletAddress) -> Result, BackendError> { unimplemented!() } } fn test_session() -> Session { diff --git a/crates/agentkeys-mock-server/src/db.rs b/crates/agentkeys-mock-server/src/db.rs index 2e4bd64..c34dc12 100644 --- a/crates/agentkeys-mock-server/src/db.rs +++ b/crates/agentkeys-mock-server/src/db.rs @@ -80,6 +80,23 @@ pub fn init_schema(conn: &Connection) -> Result<()> { created_at INTEGER NOT NULL, PRIMARY KEY (wallet_address, identity_type, identity_value) ); + + CREATE TABLE IF NOT EXISTS inboxes ( + address TEXT PRIMARY KEY, + agent_wallet TEXT NOT NULL REFERENCES accounts(wallet_address), + created_at INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS inbox_messages ( + msg_id TEXT PRIMARY KEY, + address TEXT NOT NULL REFERENCES inboxes(address), + from_addr TEXT NOT NULL, + subject TEXT, + body TEXT, + received_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_inbox_msgs_addr_time ON inbox_messages(address, received_at DESC); ", ) } diff --git a/crates/agentkeys-mock-server/src/handlers/audit.rs b/crates/agentkeys-mock-server/src/handlers/audit.rs index 1340f70..d13340e 100644 --- a/crates/agentkeys-mock-server/src/handlers/audit.rs +++ b/crates/agentkeys-mock-server/src/handlers/audit.rs @@ -3,7 +3,6 @@ use axum::{ http::HeaderMap, Json, }; -use rusqlite::params; use serde::Deserialize; use serde_json::{json, Value}; diff --git a/crates/agentkeys-mock-server/src/handlers/auth_request.rs b/crates/agentkeys-mock-server/src/handlers/auth_request.rs index 3f7766c..fe3fdf6 100644 --- a/crates/agentkeys-mock-server/src/handlers/auth_request.rs +++ b/crates/agentkeys-mock-server/src/handlers/auth_request.rs @@ -182,7 +182,7 @@ pub async fn open_auth_request( // Derive pair code uniquely: use nonce + request_details hash to avoid collisions let mut hasher = Sha256::new(); - hasher.update(&nonce); + hasher.update(nonce); hasher.update(&request_details); let hash = hasher.finalize(); let pair_code = hex::encode(&hash[..4]).to_uppercase(); @@ -193,7 +193,7 @@ pub async fn open_auth_request( // Compute nonce hash for the response let mut nonce_hasher = Sha256::new(); - nonce_hasher.update(&nonce); + nonce_hasher.update(nonce); let nonce_hash = nonce_hasher.finalize().to_vec(); let db = state.db.lock().unwrap(); @@ -400,7 +400,7 @@ pub async fn approve_auth_request( hasher.update(&request_details); hasher.update(&child_pubkey); hasher.update(session.token.as_bytes()); - hasher.update(&created_at.to_be_bytes()); + hasher.update(created_at.to_be_bytes()); hasher.update(&nonce); let hash_bytes = hasher.finalize(); diff --git a/crates/agentkeys-mock-server/src/handlers/inbox.rs b/crates/agentkeys-mock-server/src/handlers/inbox.rs new file mode 100644 index 0000000..98f0ce7 --- /dev/null +++ b/crates/agentkeys-mock-server/src/handlers/inbox.rs @@ -0,0 +1,385 @@ +use axum::{ + extract::{Query, State}, + http::HeaderMap, + Json, +}; +use rusqlite::params; +use serde::Deserialize; +use serde_json::{json, Value}; + +use crate::{ + auth::{extract_bearer_token, is_owner_of, now_secs, validate_session}, + error::{AppError, AppResult}, + state::SharedState, +}; + +fn email_domain() -> String { + std::env::var("AGENTKEYS_EMAIL_DOMAIN").unwrap_or_else(|_| "agentkeys-email.io".to_string()) +} + +fn generate_inbox_address() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + let bytes: [u8; 3] = rng.gen(); + format!("bot-{}@{}", hex::encode(bytes), email_domain()) +} + +fn generate_msg_id() -> String { + use rand::Rng; + let mut rng = rand::thread_rng(); + let bytes: [u8; 16] = rng.gen(); + let hex = hex::encode(bytes); + format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) +} + +pub async fn provision_inbox( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> AppResult> { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(extract_bearer_token) + .ok_or_else(|| AppError::unauthorized("missing Authorization header"))?; + + let session = validate_session(&state, token)?; + + let agent_id = body + .get("agent_id") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::bad_request("agent_id required"))?; + + let db = state.db.lock().unwrap(); + + if !is_owner_of(&db, &session.wallet_address, agent_id) { + return Err(AppError::forbidden(format!( + "session does not own agent {}", + agent_id + ))); + } + + let address = generate_inbox_address(); + let now = now_secs(); + + db.execute( + "INSERT INTO inboxes (address, agent_wallet, created_at) VALUES (?1, ?2, ?3)", + params![address, agent_id, now], + ) + .map_err(|e| AppError::internal(e.to_string()))?; + + Ok(Json(json!({ "address": address, "agent_wallet": agent_id }))) +} + +pub async fn deliver_inbox( + State(state): State, + Json(body): Json, +) -> AppResult> { + let address = body + .get("address") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::bad_request("address required"))?; + let from_addr = body + .get("from") + .and_then(|v| v.as_str()) + .ok_or_else(|| AppError::bad_request("from required"))?; + let subject = body.get("subject").and_then(|v| v.as_str()); + let message_body = body.get("body").and_then(|v| v.as_str()); + + let db = state.db.lock().unwrap(); + + let inbox_exists: bool = db + .query_row( + "SELECT 1 FROM inboxes WHERE address = ?1", + params![address], + |_| Ok(true), + ) + .unwrap_or(false); + + if !inbox_exists { + return Err(AppError::not_found(format!("inbox not found: {}", address))); + } + + let msg_id = generate_msg_id(); + let now = now_secs(); + + db.execute( + "INSERT INTO inbox_messages (msg_id, address, from_addr, subject, body, received_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![msg_id, address, from_addr, subject, message_body, now], + ) + .map_err(|e| AppError::internal(e.to_string()))?; + + Ok(Json(json!({ "msg_id": msg_id }))) +} + +#[derive(Deserialize)] +pub struct ListInboxesQuery { + pub agent_id: String, +} + +pub async fn list_inboxes( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> AppResult> { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(extract_bearer_token) + .ok_or_else(|| AppError::unauthorized("missing Authorization header"))?; + + let session = validate_session(&state, token)?; + + let agent_id = &query.agent_id; + let db = state.db.lock().unwrap(); + + if !is_owner_of(&db, &session.wallet_address, agent_id) { + return Err(AppError::forbidden(format!( + "session does not own agent {}", + agent_id + ))); + } + + let mut stmt = db + .prepare("SELECT address FROM inboxes WHERE agent_wallet = ?1 ORDER BY created_at ASC") + .map_err(|e| AppError::internal(e.to_string()))?; + + let addresses: Vec = stmt + .query_map(params![agent_id], |row| { + let addr: String = row.get(0)?; + Ok(Value::String(addr)) + }) + .map_err(|e| AppError::internal(e.to_string()))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(Json(Value::Array(addresses))) +} + +#[derive(Deserialize)] +pub struct ListMessagesQuery { + pub address: String, +} + +pub async fn list_messages( + State(state): State, + headers: HeaderMap, + Query(query): Query, +) -> AppResult> { + let token = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(extract_bearer_token) + .ok_or_else(|| AppError::unauthorized("missing Authorization header"))?; + + let session = validate_session(&state, token)?; + + let address = &query.address; + let db = state.db.lock().unwrap(); + + let agent_wallet: String = db + .query_row( + "SELECT agent_wallet FROM inboxes WHERE address = ?1", + params![address], + |row| row.get(0), + ) + .map_err(|_| AppError::not_found(format!("inbox not found: {}", address)))?; + + if !is_owner_of(&db, &session.wallet_address, &agent_wallet) { + return Err(AppError::forbidden(format!( + "session does not own inbox {}", + address + ))); + } + + let mut stmt = db + .prepare( + "SELECT msg_id, from_addr, subject, body, received_at + FROM inbox_messages + WHERE address = ?1 + ORDER BY received_at DESC", + ) + .map_err(|e| AppError::internal(e.to_string()))?; + + let messages: Vec = stmt + .query_map(params![address], |row| { + Ok(json!({ + "msg_id": row.get::<_, String>(0)?, + "from": row.get::<_, String>(1)?, + "subject": row.get::<_, Option>(2)?, + "body": row.get::<_, Option>(3)?, + "received_at": row.get::<_, u64>(4)?, + })) + }) + .map_err(|e| AppError::internal(e.to_string()))? + .filter_map(|r| r.ok()) + .collect(); + + Ok(Json(json!(messages))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{create_router, db, state::AppState}; + use axum::Router; + use axum::body::Body; + use axum::http::{Method, Request, StatusCode}; + use http_body_util::BodyExt; + use serde_json::{json, Value}; + use std::sync::Arc; + use tower::ServiceExt; + + fn setup() -> Router { + let conn = rusqlite::Connection::open_in_memory().unwrap(); + db::init_schema(&conn).unwrap(); + let state = Arc::new(AppState::new(conn)); + create_router(state) + } + + async fn body_json(body: axum::body::Body) -> Value { + let bytes = body.collect().await.unwrap().to_bytes(); + serde_json::from_slice(&bytes).unwrap_or(Value::Null) + } + + async fn post_json(app: Router, path: &str, body: Value) -> (StatusCode, Value) { + let req = Request::builder() + .method(Method::POST) + .uri(path) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let json = body_json(resp.into_body()).await; + (status, json) + } + + async fn post_json_auth( + app: Router, + path: &str, + token: &str, + body: Value, + ) -> (StatusCode, Value) { + let req = Request::builder() + .method(Method::POST) + .uri(path) + .header("content-type", "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(serde_json::to_string(&body).unwrap())) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let json = body_json(resp.into_body()).await; + (status, json) + } + + async fn get_json_auth(app: Router, path: &str, token: &str) -> (StatusCode, Value) { + let req = Request::builder() + .method(Method::GET) + .uri(path) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let json = body_json(resp.into_body()).await; + (status, json) + } + + async fn create_session(app: Router) -> (String, String, Router) { + let (status, json) = post_json( + app.clone(), + "/session/create", + json!({ "auth_token": format!("tok-{}", rand::random::()) }), + ) + .await; + assert_eq!(status, StatusCode::OK, "create session failed: {json}"); + let session = json["session"].as_str().unwrap().to_string(); + let wallet = json["wallet"].as_str().unwrap().to_string(); + (session, wallet, app) + } + + async fn provision(app: Router, token: &str, agent_wallet: &str) -> (StatusCode, Value) { + post_json_auth( + app, + "/mock/inbox/provision", + token, + json!({ "agent_id": agent_wallet }), + ) + .await + } + + #[tokio::test] + async fn provision_returns_unique_address() { + let app = setup(); + let (token, wallet, app) = create_session(app).await; + + let mut addresses = std::collections::HashSet::new(); + for _ in 0..10 { + let (status, json) = provision(app.clone(), &token, &wallet).await; + assert_eq!(status, StatusCode::OK, "provision failed: {json}"); + let addr = json["address"].as_str().unwrap().to_string(); + addresses.insert(addr); + } + assert_eq!(addresses.len(), 10, "expected 10 distinct addresses"); + } + + #[tokio::test] + async fn deliver_and_fetch_roundtrip() { + let app = setup(); + let (token, wallet, app) = create_session(app).await; + + let (status, json) = provision(app.clone(), &token, &wallet).await; + assert_eq!(status, StatusCode::OK, "provision failed: {json}"); + let address = json["address"].as_str().unwrap().to_string(); + + let (status, json) = post_json( + app.clone(), + "/mock/inbox/deliver", + json!({ + "address": address, + "from": "sender@example.com", + "subject": "Hello", + "body": "World", + }), + ) + .await; + assert_eq!(status, StatusCode::OK, "deliver failed: {json}"); + let msg_id = json["msg_id"].as_str().unwrap().to_string(); + + let path = format!("/mock/inbox/messages?address={}", address); + let (status, json) = get_json_auth(app.clone(), &path, &token).await; + assert_eq!(status, StatusCode::OK, "list failed: {json}"); + let messages = json.as_array().unwrap(); + assert_eq!(messages.len(), 1); + assert_eq!(messages[0]["msg_id"].as_str().unwrap(), msg_id); + assert_eq!(messages[0]["from"].as_str().unwrap(), "sender@example.com"); + assert_eq!(messages[0]["subject"].as_str().unwrap(), "Hello"); + assert_eq!(messages[0]["body"].as_str().unwrap(), "World"); + } + + #[tokio::test] + async fn cross_session_list_denied() { + let app = setup(); + + let (token_a, wallet_a, app) = create_session(app).await; + let (token_b, _wallet_b, app) = create_session(app).await; + + let (status, json) = provision(app.clone(), &token_a, &wallet_a).await; + assert_eq!(status, StatusCode::OK, "provision failed: {json}"); + let address = json["address"].as_str().unwrap().to_string(); + + let path = format!("/mock/inbox/messages?address={}", address); + let (status, _) = get_json_auth(app.clone(), &path, &token_b).await; + assert_eq!(status, StatusCode::FORBIDDEN, "expected 403 for session B"); + } +} diff --git a/crates/agentkeys-mock-server/src/handlers/mod.rs b/crates/agentkeys-mock-server/src/handlers/mod.rs index 50193bf..92055f8 100644 --- a/crates/agentkeys-mock-server/src/handlers/mod.rs +++ b/crates/agentkeys-mock-server/src/handlers/mod.rs @@ -2,5 +2,6 @@ pub mod audit; pub mod auth_request; pub mod credential; pub mod identity; +pub mod inbox; pub mod rendezvous; pub mod session; diff --git a/crates/agentkeys-mock-server/src/handlers/session.rs b/crates/agentkeys-mock-server/src/handlers/session.rs index d541ae6..fb81042 100644 --- a/crates/agentkeys-mock-server/src/handlers/session.rs +++ b/crates/agentkeys-mock-server/src/handlers/session.rs @@ -8,11 +8,11 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use crate::{ - auth::{derive_pair_code_from_nonce, extract_bearer_token, generate_nonce, generate_token, generate_wallet_address, is_owner_of, now_secs, validate_session}, + auth::{extract_bearer_token, generate_token, generate_wallet_address, is_owner_of, now_secs, validate_session}, error::{AppError, AppResult}, state::SharedState, }; -use agentkeys_types::{AuthToken, Scope}; +use agentkeys_types::Scope; use ed25519_dalek::SigningKey; /// Session token TTL in seconds — 30 days. diff --git a/crates/agentkeys-mock-server/src/lib.rs b/crates/agentkeys-mock-server/src/lib.rs index 6d0a2e2..dbeca30 100644 --- a/crates/agentkeys-mock-server/src/lib.rs +++ b/crates/agentkeys-mock-server/src/lib.rs @@ -9,7 +9,6 @@ use axum::{ Router, routing::{delete, get, post, put}, }; -use std::sync::Arc; use state::SharedState; @@ -44,6 +43,11 @@ pub fn create_router(state: SharedState) -> Router { // Identity .route("/identity/link", post(handlers::identity::link_identity)) .route("/identity/resolve", get(handlers::identity::resolve_identity)) + // Inbox + .route("/mock/inbox/provision", post(handlers::inbox::provision_inbox)) + .route("/mock/inbox/deliver", post(handlers::inbox::deliver_inbox)) + .route("/mock/inbox/messages", get(handlers::inbox::list_messages)) + .route("/mock/inbox/list", get(handlers::inbox::list_inboxes)) // Health .route("/health", get(|| async { "ok" })) .with_state(state) diff --git a/crates/agentkeys-mock-server/src/test_client.rs b/crates/agentkeys-mock-server/src/test_client.rs index b504828..d1a47ef 100644 --- a/crates/agentkeys-mock-server/src/test_client.rs +++ b/crates/agentkeys-mock-server/src/test_client.rs @@ -11,8 +11,8 @@ use tower::ServiceExt; use agentkeys_core::backend::{BackendError, CredentialBackend}; use agentkeys_types::{ AuditEvent, AuditFilter, AuthRequest, AuthRequestId, AuthRequestType, CanonicalBytes, - EncryptedPairPayload, OpenedAuthRequest, PairCode, PairPayload, PublicKey, RegistrationToken, - Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, + EncryptedPairPayload, InboxAddress, OpenedAuthRequest, PairCode, PairPayload, PublicKey, + RegistrationToken, Scope, ServiceName, Session, SignedAuthDecision, WalletAddress, }; use crate::{create_router, db, state::{AppState, SharedState}}; @@ -818,4 +818,79 @@ impl CredentialBackend for InProcessBackend { }; Ok((session, wallet)) } + + async fn provision_inbox( + &self, + session: &Session, + agent_id: &WalletAddress, + ) -> Result { + let body = self + .post_with_session( + "/mock/inbox/provision", + session, + json!({ "agent_id": agent_id.0 }), + ) + .await?; + let address = body["address"] + .as_str() + .ok_or_else(|| BackendError::Transport("missing address".into()))? + .to_string(); + Ok(InboxAddress(address)) + } + + async fn list_inboxes( + &self, + session: &Session, + agent_id: &WalletAddress, + ) -> Result, BackendError> { + let path = format!("/mock/inbox/list?agent_id={}", pct_encode(&agent_id.0)); + let body = self.get_with_session(&path, session).await?; + let addresses = body + .as_array() + .ok_or_else(|| BackendError::Transport("expected array".into()))? + .iter() + .filter_map(|v| v.as_str().map(|s| InboxAddress(s.to_string()))) + .collect(); + Ok(addresses) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use agentkeys_core::backend::CredentialBackend; + use agentkeys_types::AuthToken; + + async fn create_session_for_tests() -> (InProcessBackend, Session, WalletAddress) { + let backend = InProcessBackend::new(); + let (session, wallet) = backend + .create_session(AuthToken::Mock("test-token".to_string())) + .await + .unwrap(); + (backend, session, wallet) + } + + #[tokio::test] + async fn provision_inbox_returns_bot_address() { + let (backend, session, wallet) = create_session_for_tests().await; + let address = backend.provision_inbox(&session, &wallet).await.unwrap(); + assert!( + address.0.starts_with("bot-") && address.0.contains('@'), + "expected bot-*@domain address, got: {}", + address.0 + ); + } + + #[tokio::test] + async fn list_inboxes_returns_provisioned_addresses() { + let (backend, session, wallet) = create_session_for_tests().await; + + let addr1 = backend.provision_inbox(&session, &wallet).await.unwrap(); + let addr2 = backend.provision_inbox(&session, &wallet).await.unwrap(); + + let inboxes = backend.list_inboxes(&session, &wallet).await.unwrap(); + assert_eq!(inboxes.len(), 2, "expected 2 inboxes"); + assert!(inboxes.contains(&addr1)); + assert!(inboxes.contains(&addr2)); + } } diff --git a/crates/agentkeys-provisioner/src/orchestrator.rs b/crates/agentkeys-provisioner/src/orchestrator.rs index 972c024..a4e4c26 100644 --- a/crates/agentkeys-provisioner/src/orchestrator.rs +++ b/crates/agentkeys-provisioner/src/orchestrator.rs @@ -1,14 +1,14 @@ use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; -use std::time::Instant; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; use agentkeys_core::backend::CredentialBackend; use agentkeys_types::{ProvisionEvent, ServiceName, Session, TripwireKind, WalletAddress}; use crate::error::{ProvisionError, ProvisionResult}; use crate::metrics::{self, ProvisionMetric, VerificationResultLabel}; -use crate::subprocess::{spawn_and_collect, SubprocessConfig}; +use crate::subprocess::{spawn_and_collect, SubprocessConfig, SubprocessOutcome}; #[derive(Debug, Clone)] pub struct ActiveProvision { @@ -86,6 +86,37 @@ impl Drop for ProvisionGuard { } } +/// Best-effort dump of subprocess output to `~/.agentkeys/logs/provision--.log`. +/// Returns the file path if the write succeeded. Never errors — failure to write the log +/// must not mask the underlying provision failure. +fn write_provision_log(service: &str, outcome: &SubprocessOutcome) -> Option { + let home = std::env::var("HOME").ok().map(PathBuf::from)?; + let dir = home.join(".agentkeys").join("logs"); + std::fs::create_dir_all(&dir).ok()?; + let ts = SystemTime::now().duration_since(UNIX_EPOCH).ok()?.as_secs(); + let safe_service: String = service + .chars() + .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' }) + .collect(); + let path = dir.join(format!("provision-{}-{}.log", safe_service, ts)); + + let mut body = String::new(); + body.push_str(&format!( + "service: {}\nexit_code: {:?}\nevents_emitted: {}\n\n=== subprocess stdout events ===\n", + service, + outcome.exit_code, + outcome.events.len() + )); + for ev in &outcome.events { + body.push_str(&format!("{:?}\n", ev)); + } + body.push_str("\n=== subprocess stderr ===\n"); + body.push_str(&outcome.stderr); + + std::fs::write(&path, body).ok()?; + Some(path) +} + /// Returns first 8 chars + `****...` + last 4. For keys shorter than 12 chars returns `****`. pub fn mask_key(key: &str) -> String { if key.len() < 12 { @@ -190,7 +221,26 @@ pub async fn run_provision( } let raw_key = api_key.ok_or_else(|| { - ProvisionError::Internal("subprocess ended without terminal event".to_string()) + let stderr_tail: String = outcome + .stderr + .lines() + .rev() + .take(20) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("\n"); + let log_hint = match write_provision_log(service, &outcome) { + Some(path) => format!("full log: {}", path.display()), + None => "full log: (unable to write ~/.agentkeys/logs — check HOME + permissions)".to_string(), + }; + ProvisionError::Internal(format!( + "subprocess ended without terminal event (exit {:?}). {}. stderr tail:\n{}", + outcome.exit_code, + log_hint, + if stderr_tail.is_empty() { "(empty)" } else { stderr_tail.as_str() } + )) })?; let masked = mask_key(&raw_key); @@ -338,6 +388,8 @@ mod orchestrate { async fn resolve_identity(&self, _: &Session, _: &str) -> Result { unimplemented!() } async fn get_scope(&self, _: &Session, _: &WalletAddress) -> Result, BackendError> { unimplemented!() } async fn update_scope(&self, _: &Session, _: &WalletAddress, _: &Scope) -> Result<(), BackendError> { unimplemented!() } + async fn provision_inbox(&self, _: &Session, _: &WalletAddress) -> Result { unimplemented!() } + async fn list_inboxes(&self, _: &Session, _: &WalletAddress) -> Result, BackendError> { unimplemented!() } } #[tokio::test] diff --git a/crates/agentkeys-types/src/lib.rs b/crates/agentkeys-types/src/lib.rs index 476d67a..fb32789 100644 --- a/crates/agentkeys-types/src/lib.rs +++ b/crates/agentkeys-types/src/lib.rs @@ -1,3 +1,5 @@ +use std::fmt; + use serde::{Deserialize, Serialize}; pub mod provision; @@ -7,6 +9,15 @@ pub use provision::{ProvisionErrorCode, ProvisionEvent, TripwireKind}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub struct WalletAddress(pub String); +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub struct InboxAddress(pub String); + +impl fmt::Display for InboxAddress { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct Session { pub token: String, diff --git a/docs/manual-test-stage5.md b/docs/manual-test-stage5.md index 47d8aca..3b8206c 100644 --- a/docs/manual-test-stage5.md +++ b/docs/manual-test-stage5.md @@ -30,70 +30,143 @@ For the demo-only purpose of Stage 5, the goal is the **shortest path to a runni > **This is a temporary demo solution.** For production (v0.1), the agent mailbox moves to SES-hosted `*@agentkeys-email.io` under the three-layer `TokenAuthority` abstraction. See the [email-system wiki page](../wiki/email-system.md) for the full architecture and why we're running demo-and-production on different backends deliberately. -#### 🚀 Demo path: dedicated personal Gmail + TOTP + app password +#### 🚀 Demo path: your existing Gmail + plus-addressing + app password -Why dedicated (not your personal inbox with plus-addressing): the agent gets a clean inbox it fully controls, no personal mail pollution, cleanup is a single account-delete. +Why plus-addressing as the primary demo path: -**1. Create a fresh Gmail account for the bot.** +- **Unique email per run.** OpenRouter's sign-up/sign-in page is a single URL — if you submit an email that already has an account, you land on a returning-user screen the scraper was not designed to traverse, and the provision fails with a `no terminal event`-style error. `you+or-@gmail.com` is a fresh address to OpenRouter on every run, so every run hits the pristine signup path. +- **Zero account creation.** Uses your existing Gmail — no new Google account, no new phone verification. +- **Single inbox to clean up.** The OpenRouter confirmation mail lands in your real inbox; delete one thread after the demo and you're done. +- **Scales to repeat testing.** Rotate the local-part (`+or-1`, `+or-2`, …) or include a timestamp and you have DWD-equivalent disposable emails without building DWD. -Sign up at [accounts.google.com](https://accounts.google.com) with a name like `wildmeta-stage5-demo@gmail.com`. Google will ask for a recovery phone — use your personal phone; you only need it once for step 2. +**1. Generate a Gmail app password for IMAP.** -**2. Enable 2-Step Verification and enroll TOTP as the second factor.** +- Requires 2FA enabled on your Google account. If not already enabled: [myaccount.google.com](https://myaccount.google.com) → Security → turn on 2-Step Verification (TOTP or SMS is fine; enrollment is a one-time cost). +- Visit [myaccount.google.com/apppasswords](https://myaccount.google.com/apppasswords). Create one named `agentkeys-stage5`. Google gives you a 16-character password. +- Copy immediately — it's shown once. Revoke anytime from the same page. -Gmail IMAP access chain: `app password` requires `2FA enabled` requires `second factor enrolled`. Using an authenticator app as that second factor makes the account non-interactive after this one-time enrollment. +**2. Export the env vars.** -- Open [myaccount.google.com](https://myaccount.google.com) → **Security** -- **Turn on 2-Step Verification.** Google sends an SMS to your recovery phone to start enrollment. -- Under 2-Step Verification settings, add **Authenticator app** as a second step. Google shows a QR code and a secret. -- Scan into Google Authenticator / Authy / 1Password / Bitwarden / whatever TOTP client you already use. You now own the second factor. -- (Optional) once TOTP is active, you can drop SMS as a 2FA method — Google keeps the phone for account recovery but stops using it as a live second factor. +The scraper splits **IMAP login** from **signup email**. Set both: -**3. Generate an app password for IMAP.** +```bash +export AGENTKEYS_EMAIL_BACKEND=gmail -- Visit [myaccount.google.com/apppasswords](https://myaccount.google.com/apppasswords). -- Create one named "agentkeys-stage5". Google gives you a 16-character password. -- Copy it immediately — it's shown once. Revoke anytime from the same page. +# IMAP login — must be the canonical Gmail address. +export AGENTKEYS_EMAIL_USER="you@gmail.com" +export AGENTKEYS_EMAIL_PASSWORD="xxxx xxxx xxxx xxxx" # 16-char app password -**4. Export the four env vars.** +# What we type into OpenRouter's signup form. +# Plus-addressed alias so OpenRouter sees a brand-new email per run; +# mail is still delivered to you@gmail.com. +export AGENTKEYS_SIGNUP_EMAIL="you+or-$(date +%s)@gmail.com" -```bash -export AGENTKEYS_EMAIL_BACKEND=gmail -export AGENTKEYS_EMAIL_USER="wildmeta-stage5-demo@gmail.com" # the bot account from step 1 -export AGENTKEYS_EMAIL_PASSWORD="xxxx xxxx xxxx xxxx" # 16-char app password from step 3 export AGENTKEYS_EMAIL_HOST="imap.gmail.com" export AGENTKEYS_EMAIL_PORT="993" ``` +> **Why two email vars.** `AGENTKEYS_EMAIL_USER` is the IMAP login — Gmail IMAP only accepts your canonical address (plus-addressing aliases are rejected at login). `AGENTKEYS_SIGNUP_EMAIL` is what we fill into the service's sign-up form — plus-addressing works there because SMTP delivery honors the `+alias` suffix. If `AGENTKEYS_SIGNUP_EMAIL` is unset, the scraper falls back to `AGENTKEYS_EMAIL_USER` — which is fine for a dedicated bot account (see alternative below) but guarantees a "account already exists" collision if you reuse a canonical address across runs. + Once the app password is set, the demo sees **zero 2FA prompts**. App passwords bypass 2FA by design — they're Google's non-interactive credential, scoped to IMAP only, revocable anytime. -**5. Daemon running and paired** — see the Stage 4 manual test guide. +**3. Build binaries + install provisioner-script deps (one-time).** + +```bash +cd ~/Projects/agentkeys +cargo build --workspace --release +npm install --prefix provisioner-scripts +npx playwright install chromium --with-deps +```
-Alternative: Google Workspace DWD (for operators with an existing Workspace subscription) +Alternative: dedicated throwaway Gmail (cleanest but more setup) -See [`docs/stage5-workspace-email-setup.md`](stage5-workspace-email-setup.md). That path mints a throwaway `stage5test-@wildmeta.ai` per run, reads its inbox via the Gmail API (no app password, no interactive OAuth), and deletes the user at the end. One-time ~20-minute admin setup + currently 3-5 days of code work to replace the `imapflow` fetcher with a Gmail-API fetcher that uses DWD impersonation. Longer upfront cost than the dedicated-Gmail demo path, but the right choice for enterprise deployments that already run Workspace. +Create a fresh bot Gmail (`wildmeta-stage5-demo@gmail.com`), enable 2FA + TOTP, generate an app password. Set `AGENTKEYS_EMAIL_USER` to the bot address; leave `AGENTKEYS_SIGNUP_EMAIL` unset. One-time ~10 minutes setup; gives you a fully controlled inbox with no personal-mail pollution. Re-runs need `--force` or account-delete between attempts because the bot address itself will collide.
-Alternative: plus-addressed personal Gmail (shared-inbox quick demo) +Alternative: Google Workspace DWD (for operators with an existing Workspace subscription) -If you don't want to create a dedicated account and are OK with one-off OpenRouter mail landing in your real inbox, plus-addressing on your existing Gmail works for a single demo run. +See [`docs/stage5-workspace-email-setup.md`](stage5-workspace-email-setup.md). That path mints a throwaway `stage5test-@wildmeta.ai` per run, reads its inbox via the Gmail API (no app password, no interactive OAuth), and deletes the user at the end. One-time ~20-minute admin setup + currently 3-5 days of code work to replace the `imapflow` fetcher with a Gmail-API fetcher that uses DWD impersonation. Right choice for enterprise deployments that already run Workspace; overkill for the demo. -1. **Your existing personal Gmail account** — plus-addressing is a Gmail-native feature: mail sent to `you+anything@gmail.com` is delivered to `you@gmail.com` without any configuration. A single inbox supports unlimited test aliases (`you+stage5test-20260418@gmail.com`). -2. **Gmail app password** (not your regular password) — generate at https://myaccount.google.com/apppasswords. Scoped to IMAP access only; revoke after the demo. -3. **Environment:** - ```bash - export AGENTKEYS_EMAIL_BACKEND=gmail - export AGENTKEYS_EMAIL_USER="you@gmail.com" # your real Gmail; Stage 5a appends +alias at signup - export AGENTKEYS_EMAIL_PASSWORD="" # NOT your normal Google password - export AGENTKEYS_EMAIL_HOST="imap.gmail.com" - export AGENTKEYS_EMAIL_PORT="993" - ``` +
-Downside: the agent doesn't fully control the inbox (shared with the human), and the OpenRouter confirmation email lingers in your personal mail until you delete it. +### Run it - +Two terminals. Everything runs from the repo root (`~/Projects/agentkeys`). + +**Terminal 1 — mock backend.** Stage 5a stores the provisioned key via the mock server (real Heima + TEE ships in v0.1). Leave this running. + +```bash +cd ~/Projects/agentkeys +cargo run --release -p agentkeys-mock-server -- --port 8090 +# Expected: "Mock server running on port 8090" +``` + +**Terminal 2 — provision.** Carry the Gmail env vars from step 2 into this shell (or re-`export` them here). Note: if you are using plus-addressing, **re-evaluate `AGENTKEYS_SIGNUP_EMAIL` for every run** so the timestamp is fresh and OpenRouter sees a new email — otherwise your second run will collide with the first run's account. + +```bash +cd ~/Projects/agentkeys +BIN=$(pwd)/target/release/agentkeys +BACKEND=http://127.0.0.1:8090 + +# 1. Initialize the master session (one-time per shell / mock restart). +$BIN --backend $BACKEND init --mock-token stage5-demo +# Expected: wallet printed; ~/.agentkeys/master/session.json created. + +# 2. Sanity-check the email env vars landed in this shell. +env | grep -E 'AGENTKEYS_(EMAIL|SIGNUP)_' +# Expected: AGENTKEYS_EMAIL_{BACKEND,USER,PASSWORD,HOST,PORT} and AGENTKEYS_SIGNUP_EMAIL. +# If AGENTKEYS_SIGNUP_EMAIL is missing, the scraper falls back to AGENTKEYS_EMAIL_USER, +# which will hit "account already exists" on the second run against OpenRouter. + +# 3. Re-seed a fresh signup alias for this run (plus-addressing path only). +export AGENTKEYS_SIGNUP_EMAIL="you+or-$(date +%s)@gmail.com" + +# 4. Run the live OpenRouter provision. +$BIN --backend $BACKEND provision openrouter +# Expect ~30-90 s: browser opens headless, account created, +# email verified, API key extracted + verified, stored in the mock backend. +``` + +**What this does under the hood:** + +- `init` authenticates the master CLI to the mock backend and caches the session token (OS keychain on macOS/Linux with keychain, file fallback otherwise). +- `provision openrouter` runs `npx tsx provisioner-scripts/src/scrapers/openrouter.ts` against a real Chromium session, uses the Gmail IMAP creds from your exported env to read the confirmation email, extracts + verifies the key against `https://openrouter.ai/api/v1/models`, and stores it into the mock backend under the master session's wallet. +- No daemon, no pairing — Stage 5a provision runs entirely as the master CLI. Daemon + pairing are Stage 4's flow for agent-side credential access, not needed for the live provision demo. + +**After it succeeds:** + +```bash +# Read the full stored key back. +$BIN --backend $BACKEND read openrouter +# Expected: sk-or-v1-... + +# Verify it works against OpenRouter. +curl -s -H "Authorization: Bearer $($BIN --backend $BACKEND read openrouter)" \ + https://openrouter.ai/api/v1/models | head -c 200 +# Expected: HTTP 200 + a JSON body starting with {"data":[... +``` + +**Artifacts you can inspect:** + +- `~/.agentkeys/master/session.json` — the master session (wallet + bearer token). +- `~/.agentkeys/logs/provision-openrouter-.log` — **written automatically when a provision fails with "no terminal event."** Contains the exit code, every event the subprocess emitted, and the full captured stderr. `ls -lt ~/.agentkeys/logs/ | head` to find the most recent. +- Stderr of `provision openrouter` — the single-shot step lines shown under "Expected behavior" below. + +**Debugging a failure:** + +1. Check the error message on stderr — if it ends with `full log: /path/to/provision-openrouter-.log`, that file has the full signal. +2. `cat` the log file. The `=== subprocess stderr ===` section usually shows the real cause (Playwright browser-launch error, IMAP connection refused, an unhandled rejection from the pattern, etc.). +3. For interactive debugging, run the TS scraper directly against a visible browser: + ```bash + # Temporarily flip headless:false at provisioner-scripts/src/scrapers/openrouter.ts:~116, + # then: + cd ~/Projects/agentkeys + npx tsx provisioner-scripts/src/scrapers/openrouter.ts + ``` + You'll see the page in real time — instant diagnosis for selector drift, returning-user UI paths, or CAPTCHA challenges. ### Expected behavior @@ -116,6 +189,8 @@ Downside: the agent doesn't fully control the inbox (shared with the human), and ### Failure modes to watch for +- **"subprocess ended without terminal event"** — the scraper crashed before emitting any event (Playwright browser-launch failed, IMAP connection refused, unhandled rejection, etc.). The error message now ends with `full log: ~/.agentkeys/logs/provision-openrouter-.log` — open that file; the `=== subprocess stderr ===` section has the real cause. If stderr is empty, re-run the TS scraper directly with `npx tsx provisioner-scripts/src/scrapers/openrouter.ts` and watch the node-side output. +- **"account already exists" (returning-user path)** — OpenRouter's `/auth` is signup+signin on one URL. If `AGENTKEYS_SIGNUP_EMAIL` is an address that already has an OpenRouter account, the site lands on a returning-user UI the scraper can't traverse, and you'll get a `selector_timeout` tripwire or (if the path is weirder) a "no terminal event." Re-evaluate `AGENTKEYS_SIGNUP_EMAIL` with a fresh timestamp (`you+or-$(date +%s)@gmail.com`) and retry. - **CAPTCHA / Cloudflare challenge** — the Tier 2 script does not solve CAPTCHAs. Expect a Tripwire event with `kind: selector_timeout`. This is the signal that Stage 5b's agentic fallback is needed. Until 5b ships, abort and retry from a different IP. - **Email didn't arrive within 60 s** — check spam, check plus-addressing forwarding. Tripwire `email_timeout` means the IMAP fetch exhausted its polling window. - **Key verification fails with `phantom`** — the scraper extracted something key-shaped that isn't a real API key. OpenRouter may have changed its DOM; inspect the page at the success-step selector and file an issue with the HAR dump. @@ -206,20 +281,107 @@ These are slop markers. Apply the suggested `cargo clippy --fix` or hand-replace --- -## 4. What to do when Stage 5b lands +## 4. Stage 5b — CDP-connected real-Chrome scraper (partial: proven working, blocked on email duplicate) + +### What's landed + +- **[provisioner-scripts/src/scrapers/openrouter-cdp.ts](../provisioner-scripts/src/scrapers/openrouter-cdp.ts)** — connects to a user-launched real Chrome via `chromium.connectOverCDP()`, drives the OpenRouter Clerk-hosted signup form, polls Gmail IMAP for the OTP code, mints a new key on `/keys`, prints the `sk-or-v1-*` value on stdout. +- **Why CDP, not Playwright-launched Chromium:** Playwright's bundled Chromium ships with `--enable-automation` baked in. Cloudflare Turnstile detects this at runtime (error **600010** — "browser execution environment suspicious") and refuses to issue a token even when a human clicks the checkbox. Connecting to a user-launched *real* Chrome bypasses this because the browser process has no automation flags. Verified 2026-04-20: Turnstile passes invisibly in real Chrome, Clerk backend returns normal responses. + +### How to run (when you have a fresh-to-OpenRouter email) + +1. **Launch real Chrome with CDP enabled** (fresh profile, separate from your daily browsing): + ```bash + /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \ + --remote-debugging-port=9222 \ + --user-data-dir=/tmp/agentkeys-chrome-profile & + ``` + A blank Chrome window opens. Don't navigate it manually — the scraper drives it. + +2. **Export env** (Gmail IMAP creds + a signup email OpenRouter hasn't seen): + ```bash + export AGENTKEYS_EMAIL_BACKEND=gmail + export AGENTKEYS_EMAIL_USER="you@gmail.com" # canonical IMAP login + export AGENTKEYS_EMAIL_PASSWORD="" + export AGENTKEYS_EMAIL_HOST="imap.gmail.com" + export AGENTKEYS_EMAIL_PORT="993" + export AGENTKEYS_SIGNUP_EMAIL="" + export AGENTKEYS_SIGNUP_PASSWORD="" + ``` + +3. **Run the scraper:** + ```bash + cd ~/Projects/agentkeys + node --import tsx/esm provisioner-scripts/src/scrapers/openrouter-cdp.ts + ``` + Last stdout line is the `sk-or-v1-*` key. Stderr shows `[cdp]