Skip to content

Commit b9f7c38

Browse files
authored
Merge pull request #18 from litentry/fix/issue-17
fix(cli): #17 revoke command — self-revoke + revoke-by-wallet
2 parents 47edb0b + 25f9009 commit b9f7c38

15 files changed

Lines changed: 728 additions & 73 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/agentkeys-cli/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,10 @@ reqwest = { version = "0.12", features = ["json"] }
2626
assert_cmd = "2"
2727
predicates = "3"
2828
agentkeys-mock-server = { path = "../agentkeys-mock-server" }
29+
agentkeys-types = { workspace = true }
2930
tokio = { workspace = true }
3031
reqwest = { version = "0.12", features = ["json"] }
3132
axum = { version = "0.7", features = ["json"] }
3233
rusqlite = { version = "0.31", features = ["bundled"] }
3334
serde_json = { workspace = true }
35+
tempfile = "3"

crates/agentkeys-cli/src/lib.rs

Lines changed: 46 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -200,32 +200,58 @@ pub async fn cmd_run(ctx: &CommandContext, agent: &str, cmd: &[String]) -> Resul
200200
Ok(String::new())
201201
}
202202

203-
pub async fn cmd_revoke(ctx: &CommandContext, agent: &str) -> Result<String> {
203+
pub async fn cmd_revoke(ctx: &CommandContext, agent: Option<&str>) -> Result<String> {
204204
let session = ctx.load_session().context("load session (run `agentkeys init` first)")?;
205205

206-
let target_session = Session {
207-
token: agent.to_string(),
208-
wallet: WalletAddress(agent.to_string()),
209-
scope: None,
210-
created_at: 0,
211-
ttl_seconds: 0,
212-
};
213-
214206
if ctx.verbose {
215207
eprintln!("[verbose] POST {}/session/revoke", ctx.backend_url);
216-
eprintln!("[verbose] target: {}", agent);
217208
}
218209

219-
ctx.backend()
220-
.revoke_session(&session, &target_session)
221-
.await
222-
.map_err(wrap_backend_error)?;
223-
224-
let now = std::time::SystemTime::now()
225-
.duration_since(std::time::UNIX_EPOCH)
226-
.map(|d| d.as_secs())
227-
.unwrap_or(0);
228-
Ok(format!("Revoked agent={} at timestamp={}", agent, now))
210+
match agent {
211+
None => {
212+
let wallet_display = session.wallet.0.clone();
213+
ctx.backend()
214+
.revoke_session(&session, &session)
215+
.await
216+
.map_err(wrap_backend_error)?;
217+
session_store::clear_session().context("clear local session")?;
218+
Ok(format!(
219+
"Revoked current session for wallet={}. Local session wiped. Run `agentkeys init` to re-pair.",
220+
wallet_display
221+
))
222+
}
223+
Some(target_wallet_str) => {
224+
if ctx.verbose {
225+
eprintln!("[verbose] target wallet: {}", target_wallet_str);
226+
}
227+
let target_wallet = WalletAddress(target_wallet_str.to_string());
228+
ctx.backend()
229+
.revoke_by_wallet(&session, &target_wallet)
230+
.await
231+
.map_err(wrap_backend_error)?;
232+
233+
// If the target wallet IS the caller's own wallet, the just-revoked
234+
// session matches the locally-cached one. Wipe local state too so
235+
// subsequent commands fail cleanly with "no session" instead of
236+
// loading the stale revoked token (codex P2 from the original review,
237+
// tracked at issue-17 review thread).
238+
//
239+
// Wallet addresses are compared case-insensitively because the EVM
240+
// canonical form (EIP-55 mixed case) can differ from the lowercase
241+
// form returned by the mock backend.
242+
let revoked_self = session.wallet.0.eq_ignore_ascii_case(target_wallet_str);
243+
if revoked_self {
244+
session_store::clear_session()
245+
.context("clear local session after self-revoke")?;
246+
Ok(format!(
247+
"Revoked agent={} (was your own session — local state wiped, run `agentkeys init` to re-pair).",
248+
target_wallet_str
249+
))
250+
} else {
251+
Ok(format!("Revoked agent={}", target_wallet_str))
252+
}
253+
}
254+
}
229255
}
230256

231257
pub async fn cmd_teardown(ctx: &CommandContext, agent: &str) -> Result<String> {

crates/agentkeys-cli/src/main.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,12 +73,12 @@ enum Commands {
7373
},
7474

7575
#[command(
76-
about = "Revoke an agent's session",
77-
long_about = "Immediately invalidate an agent's session token.\n\nExamples:\n agentkeys revoke 0xAGENT"
76+
about = "Revoke a session",
77+
long_about = "Revoke a session. Without arguments, revokes the current session and wipes the local keychain entry (you must run `agentkeys init` again). With a wallet address, revokes all active sessions for that child agent (ownership check enforced).\n\nExamples:\n agentkeys revoke\n agentkeys revoke 0xCHILD_WALLET"
7878
)]
7979
Revoke {
80-
#[arg(help = "Agent wallet address or session token to revoke")]
81-
agent: String,
80+
#[arg(help = "Child agent wallet address to revoke (omit to revoke your own current session)", required = false)]
81+
agent: Option<String>,
8282
},
8383

8484
#[command(
@@ -155,7 +155,7 @@ async fn main() {
155155
Commands::Store { agent, service, key } => cmd_store(&ctx, agent, service, key).await,
156156
Commands::Read { agent, service } => cmd_read(&ctx, agent, service).await,
157157
Commands::Run { agent, cmd } => cmd_run(&ctx, agent, cmd).await,
158-
Commands::Revoke { agent } => cmd_revoke(&ctx, agent).await,
158+
Commands::Revoke { agent } => cmd_revoke(&ctx, agent.as_deref()).await,
159159
Commands::Teardown { agent } => cmd_teardown(&ctx, agent).await,
160160
Commands::Usage { agent, json } => {
161161
cmd_usage(&ctx, agent.as_deref(), *json).await

crates/agentkeys-cli/src/session_store.rs

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::path::PathBuf;
55
const KEYRING_SERVICE: &str = "agentkeys";
66
const KEYRING_USER: &str = "session";
77

8-
fn fallback_path() -> PathBuf {
8+
pub fn fallback_path() -> PathBuf {
99
let home = std::env::var("HOME")
1010
.or_else(|_| std::env::var("USERPROFILE"))
1111
.unwrap_or_else(|_| ".".to_string());
@@ -91,3 +91,57 @@ fn load_from_file() -> Result<Session> {
9191
.with_context(|| format!("read session file at {}", path.display()))?;
9292
serde_json::from_str(&json).context("deserialize session from file")
9393
}
94+
95+
/// Delete the locally stored session from both keyring and file.
96+
/// Best-effort: ignores "not found" errors. Returns Err only if both
97+
/// attempts failed with non-NotFound errors.
98+
///
99+
/// When `AGENTKEYS_SESSION_STORE=file` is set, the keyring branch is skipped
100+
/// entirely (no 2-second timeout, no chance of spurious keyring errors).
101+
pub fn clear_session() -> Result<()> {
102+
let keyring_result = if should_skip_keyring() {
103+
Ok(())
104+
} else {
105+
try_keyring_delete()
106+
};
107+
let file_result = delete_session_file();
108+
109+
match (keyring_result, file_result) {
110+
(Err(ke), Err(fe)) => {
111+
Err(anyhow::anyhow!("could not clear session: keyring: {}; file: {}", ke, fe))
112+
}
113+
_ => Ok(()),
114+
}
115+
}
116+
117+
fn try_keyring_delete() -> Result<()> {
118+
let (tx, rx) = std::sync::mpsc::channel::<Result<()>>();
119+
std::thread::spawn(move || {
120+
let result = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
121+
.map_err(|e| anyhow::anyhow!("{}", e))
122+
.and_then(|e| e.delete_password().map_err(|ke| anyhow::anyhow!("{}", ke)));
123+
let _ = tx.send(result);
124+
});
125+
match rx.recv_timeout(std::time::Duration::from_secs(2)) {
126+
Ok(Ok(())) => Ok(()),
127+
Ok(Err(e)) => {
128+
let msg = e.to_string().to_lowercase();
129+
if msg.contains("not found") || msg.contains("no such") || msg.contains("no password") {
130+
Ok(())
131+
} else {
132+
Err(e)
133+
}
134+
}
135+
Err(_) => Ok(()),
136+
}
137+
}
138+
139+
fn delete_session_file() -> Result<()> {
140+
let path = fallback_path();
141+
match std::fs::remove_file(&path) {
142+
Ok(()) => Ok(()),
143+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
144+
Err(e) => Err(anyhow::anyhow!("remove {}: {}", path.display(), e)),
145+
}
146+
}
147+

0 commit comments

Comments
 (0)