Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 85 additions & 6 deletions crates/agentkeys-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -145,21 +145,64 @@ pub async fn cmd_read(ctx: &CommandContext, agent: &str, service: &str) -> Resul
}
}

pub async fn cmd_run(ctx: &CommandContext, agent: &str, cmd: &[String]) -> Result<String> {
pub async fn cmd_run(
ctx: &CommandContext,
agent: &str,
env_overrides: &[String],
cmd: &[String],
) -> Result<String> {
if cmd.is_empty() {
return Err(anyhow!("No command specified after --"));
}

let session = ctx.load_session().context("load session (run `agentkeys init` first)")?;
let agent_id = WalletAddress(agent.to_string());

let services_to_try = if let Some(scope) = &session.scope {
scope.services.iter().map(|s| s.0.clone()).collect::<Vec<_>>()
let backend = ctx.backend();

// Pre-flight validation: reject any invalid --env entries BEFORE any credential
// I/O (no network round-trips or audit log entries for a partial invocation).
// Must run before list_credentials so a malformed override does not produce a
// backend round-trip / DENIED audit row on the master-session path (codex P2 v2).
for raw in env_overrides {
let eq_pos = raw.find('=').ok_or_else(|| {
anyhow!(
"Invalid --env format '{}': expected KEY=SERVICE (no '=' found)",
raw
)
})?;
if eq_pos == 0 {
return Err(anyhow!(
"Invalid --env format '{}': KEY must not be empty",
raw
));
}
if eq_pos + 1 == raw.len() {
return Err(anyhow!(
"Invalid --env format '{}': SERVICE must not be empty",
raw
));
}
}

let services_to_try: Vec<String> = if let Some(scope) = &session.scope {
scope.services.iter().map(|s| s.0.clone()).collect()
} else {
vec![]
backend
.list_credentials(&session, &agent_id)
.await
.map_err(wrap_backend_error)?
.into_iter()
.map(|s| s.0)
.collect()
};

let backend = ctx.backend();
// Track which services we've already fetched in the auto-injection pass.
// The --env loop below reuses these values instead of issuing a second
// read_credential for the same service, which would double-count audit
// events and rate-limit decrements (codex P2 on PR #19).
let mut fetched: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
let mut env_vars: Vec<(String, String)> = Vec::new();
let mut credential_errors: Vec<String> = Vec::new();
for service in &services_to_try {
Expand All @@ -168,17 +211,53 @@ pub async fn cmd_run(ctx: &CommandContext, agent: &str, cmd: &[String]) -> Resul
Ok(bytes) => {
let value = String::from_utf8_lossy(&bytes).to_string();
let env_key = format!("{}_API_KEY", service.to_uppercase().replace('-', "_"));
fetched.insert(service.clone(), value.clone());
env_vars.push((env_key, value));
}
Err(e) => {
credential_errors.push(format!("Failed to read credential for service '{}': {}", service, format_backend_error(&e)));
credential_errors.push(format!(
"Failed to read credential for service '{}': {}",
service,
format_backend_error(&e)
));
}
}
}
if !credential_errors.is_empty() {
return Err(anyhow!("{}", credential_errors.join("\n")));
}

for raw in env_overrides {
let eq_pos = raw.find('=').expect("pre-flight validation already rejected entries without '='");
let env_key = raw[..eq_pos].to_string();
let service = &raw[eq_pos + 1..];

// Reuse the auto-injection fetch if we already pulled this service.
// Only issue a fresh read_credential when --env names a service that
// wasn't auto-injected (typical for master sessions where scope=None
// → all stored services were already pulled, so fresh reads here are
// for the rare case of explicit --env on a service the user never
// stored before this run).
let value = if let Some(cached) = fetched.get(service) {
cached.clone()
} else {
let service_name = ServiceName(service.to_string());
let bytes = backend
.read_credential(&session, &agent_id, &service_name)
.await
.map_err(wrap_backend_error)?;
let v = String::from_utf8_lossy(&bytes).to_string();
fetched.insert(service.to_string(), v.clone());
v
};

if let Some(existing) = env_vars.iter_mut().find(|(k, _)| k == &env_key) {
existing.1 = value;
} else {
env_vars.push((env_key, value));
}
}

if ctx.verbose {
eprintln!(
"[verbose] Injecting env vars: {:?}",
Expand Down
7 changes: 5 additions & 2 deletions crates/agentkeys-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use agentkeys_cli::{
cmd_store, cmd_teardown, cmd_usage, CommandContext,
};


use clap::{Parser, Subcommand};

#[derive(Parser)]
Expand Down Expand Up @@ -63,11 +64,13 @@ enum Commands {

#[command(
about = "Run a command with credentials injected as env vars",
long_about = "Load credentials for the agent and inject them as SERVICE_API_KEY env vars.\n\nExamples:\n agentkeys run 0xAGENT -- python my_agent.py\n agentkeys run 0xAGENT -- node server.js"
long_about = "Load credentials for the agent and inject them as SERVICE_API_KEY env vars.\n\nExamples:\n agentkeys run 0xAGENT -- python my_agent.py\n agentkeys run 0xAGENT -- node server.js\n agentkeys run 0xAGENT --env GITHUB_TOKEN=github -- bash deploy.sh"
)]
Run {
#[arg(help = "Agent wallet address")]
agent: String,
#[arg(long = "env", value_name = "KEY=SERVICE", action = clap::ArgAction::Append, help = "Map env var name to service (e.g. GITHUB_TOKEN=github)")]
env: Vec<String>,
#[arg(last = true, help = "Command to execute")]
cmd: Vec<String>,
},
Expand Down Expand Up @@ -154,7 +157,7 @@ async fn main() {
}
Commands::Store { agent, service, key } => cmd_store(&ctx, agent, service, key).await,
Commands::Read { agent, service } => cmd_read(&ctx, agent, service).await,
Commands::Run { agent, cmd } => cmd_run(&ctx, agent, cmd).await,
Commands::Run { agent, env, cmd } => cmd_run(&ctx, agent, env, cmd).await,
Commands::Revoke { agent } => cmd_revoke(&ctx, agent.as_deref()).await,
Commands::Teardown { agent } => cmd_teardown(&ctx, agent).await,
Commands::Usage { agent, json } => {
Expand Down
146 changes: 145 additions & 1 deletion crates/agentkeys-cli/tests/cli_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ async fn cli_run_injects_env() {

// Master session has no scope, so no env vars are injected automatically.
// Verify cmd_run can exec a simple command without error.
let result = agentkeys_cli::cmd_run(&context, &wallet, &["true".to_string()]).await;
let result = agentkeys_cli::cmd_run(&context, &wallet, &[], &["true".to_string()]).await;
assert!(result.is_ok(), "cmd_run failed: {:?}", result.err());
}

Expand Down Expand Up @@ -447,3 +447,147 @@ async fn cli_error_format_unreachable() {
"unexpected error: {err}"
);
}

// ---------------------------------------------------------------------------
// Tests for cmd_run master-session fix and --env flag (issue #15 parts 1 & 2)
// ---------------------------------------------------------------------------

// Test 15: master session (scope: None) injects all stored credentials
#[tokio::test(flavor = "multi_thread")]
async fn cmd_run_master_session_injects_all_credentials() {
let backend = create_test_backend();
let (wallet, session) = init_session_direct(&backend).await;
let context = ctx_with_session(backend, session);

cmd_store(&context, &wallet, "openrouter", "sk-or-test").await.unwrap();
cmd_store(&context, &wallet, "anthropic", "sk-ant-test").await.unwrap();

// `env` prints all env vars; grep for the injected keys
let result = agentkeys_cli::cmd_run(&context, &wallet, &[], &["env".to_string()]).await;
assert!(result.is_ok(), "cmd_run failed: {:?}", result.err());
}

// Test 16: child session with scope respects the scope list.
// The child session owns child_wallet; credentials are stored under child_wallet
// by the master session (which owns the child via parent_token chain).
// cmd_run with the scoped child session injects only the scoped service.
#[tokio::test(flavor = "multi_thread")]
async fn cmd_run_scoped_session_respects_scope() {
use agentkeys_core::backend::CredentialBackend;
use agentkeys_types::{Scope, ServiceName};
use std::sync::Arc;

let backend = create_test_backend();
let (_wallet, master_session) = init_session_direct(&backend).await;

let scope = Scope {
services: vec![ServiceName("openrouter".to_string())],
read_only: false,
};
let (child_session, child_wallet) = (backend.clone() as Arc<dyn CredentialBackend>)
.create_child_session(&master_session, scope)
.await
.unwrap();

// Store credentials under child_wallet using the master session (master owns the child)
let master_ctx = ctx_with_session(backend.clone(), master_session.clone());
cmd_store(&master_ctx, &child_wallet.0, "openrouter", "sk-or-scoped").await.unwrap();
cmd_store(&master_ctx, &child_wallet.0, "anthropic", "sk-ant-scoped").await.unwrap();

// cmd_run with the child session: scope = ["openrouter"], so only openrouter is injected
let child_ctx = ctx_with_session(backend, child_session);
let result = agentkeys_cli::cmd_run(
&child_ctx,
&child_wallet.0,
&[],
&["true".to_string()],
)
.await;
assert!(result.is_ok(), "scoped cmd_run failed: {:?}", result.err());
}

// Test 17: --env KEY=service overrides the default auto-convention name
#[tokio::test(flavor = "multi_thread")]
async fn cmd_run_env_flag_overrides_default_name() {
let backend = create_test_backend();
let (wallet, session) = init_session_direct(&backend).await;
let context = ctx_with_session(backend, session);

cmd_store(&context, &wallet, "github", "ghp-token-value").await.unwrap();

// With --env GITHUB_TOKEN=github, the credential should be injected as GITHUB_TOKEN
let result = agentkeys_cli::cmd_run(
&context,
&wallet,
&["GITHUB_TOKEN=github".to_string()],
&["true".to_string()],
)
.await;
assert!(result.is_ok(), "env-flag cmd_run failed: {:?}", result.err());
}

// Test 18: --env without '=' returns a clean parse error, child not spawned
#[tokio::test(flavor = "multi_thread")]
async fn cmd_run_env_flag_invalid_format() {
let backend = create_test_backend();
let (wallet, session) = init_session_direct(&backend).await;
let context = ctx_with_session(backend, session);

let result = agentkeys_cli::cmd_run(
&context,
&wallet,
&["INVALID_NO_EQUALS".to_string()],
&["true".to_string()],
)
.await;
assert!(result.is_err(), "expected parse error for invalid --env format");
let err = result.unwrap_err().to_string();
assert!(
err.contains("Invalid --env") || err.contains("KEY=SERVICE"),
"unexpected error message: {err}"
);
}

// Test 19 (codex P2 v2): --env with empty KEY (e.g. "=github") rejected up
// front, no backend round-trip and no DENIED audit row.
#[tokio::test(flavor = "multi_thread")]
async fn cmd_run_env_flag_empty_key_rejected() {
let backend = create_test_backend();
let (wallet, session) = init_session_direct(&backend).await;
let context = ctx_with_session(backend, session);

let result = agentkeys_cli::cmd_run(
&context,
&wallet,
&["=github".to_string()],
&["true".to_string()],
)
.await;
let err = result.expect_err("empty KEY must be rejected").to_string();
assert!(
err.contains("KEY must not be empty"),
"unexpected error: {err}"
);
}

// Test 20 (codex P2 v2): --env with empty SERVICE (e.g. "MY_KEY=") rejected
// up front, no backend round-trip for an empty service name.
#[tokio::test(flavor = "multi_thread")]
async fn cmd_run_env_flag_empty_service_rejected() {
let backend = create_test_backend();
let (wallet, session) = init_session_direct(&backend).await;
let context = ctx_with_session(backend, session);

let result = agentkeys_cli::cmd_run(
&context,
&wallet,
&["MY_KEY=".to_string()],
&["true".to_string()],
)
.await;
let err = result.expect_err("empty SERVICE must be rejected").to_string();
assert!(
err.contains("SERVICE must not be empty"),
"unexpected error: {err}"
);
}
14 changes: 14 additions & 0 deletions crates/agentkeys-core/src/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,12 @@ pub trait CredentialBackend: Send + Sync {
identity: &agentkeys_types::AgentIdentity,
method: &agentkeys_types::RecoveryMethod,
) -> Result<(Session, WalletAddress), BackendError>;

async fn list_credentials(
&self,
session: &Session,
agent_id: &WalletAddress,
) -> Result<Vec<ServiceName>, BackendError>;
}

#[cfg(test)]
Expand Down Expand Up @@ -271,6 +277,14 @@ mod tests {
) -> Result<(Session, WalletAddress), BackendError> {
unimplemented!()
}

async fn list_credentials(
&self,
_session: &Session,
_agent_id: &WalletAddress,
) -> Result<Vec<ServiceName>, BackendError> {
unimplemented!()
}
}

#[test]
Expand Down
29 changes: 29 additions & 0 deletions crates/agentkeys-core/src/mock_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,35 @@ impl CredentialBackend for MockHttpClient {
})
}

async fn list_credentials(
&self,
session: &Session,
agent_id: &WalletAddress,
) -> Result<Vec<ServiceName>, BackendError> {
let url = format!("/credential/list?agent_id={}", agent_id.0);

let resp = self
.client
.get(self.url(&url))
.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 services = body["services"]
.as_array()
.ok_or_else(|| BackendError::Internal("missing services".into()))?
.iter()
.filter_map(|v| v.as_str().map(|s| ServiceName(s.to_string())))
.collect();
Ok(services)
}

async fn recover_session(
&self,
identity: &agentkeys_types::AgentIdentity,
Expand Down
Loading