1use alloy_primitives::{Address, B256, U256};
2use alloy_provider::Provider;
3use alloy_signer::Signer;
4use alloy_sol_types::SolCall;
5use clap::{Args, Parser};
6use eyre::{Context, Result};
7use foundry_cli::{
8 opts::{TEMPO_SESSION_ID_ENV, TransactionOpts},
9 utils::{LoadConfig, now, parse_fee_token_address},
10};
11use foundry_common::{
12 provider::ProviderBuilder,
13 sh_println, shell,
14 tempo::{
15 GeneratedSessionKey, SessionAuthorizationRequest, SessionEntry, SessionSpendLimit,
16 read_session_entry, retire_session_entry, upsert_session_entry,
17 },
18};
19use foundry_wallets::{WalletOpts, WalletSigner};
20use serde_json::json;
21use std::{
22 num::NonZeroU64,
23 process::{Command, ExitStatus},
24};
25use tempo_alloy::{TempoNetwork, provider::TempoProviderExt};
26use tempo_contracts::precompiles::IAccountKeychain;
27use tempo_primitives::transaction::{CallScope, PrimitiveSignature, SelectorRule};
28use tokio::signal;
29
30use crate::{
31 cmd::{
32 keychain::{
33 KeychainTxOutcome, resolve_keychain_root_signer, send_keychain_tx_with_root_signer,
34 },
35 print_json_or,
36 tempo_policy_args::{
37 parse_period, parse_scope as parse_policy_scope, parse_selector_bytes,
38 },
39 },
40 tempo,
41 tx::SendTxOpts,
42};
43
44use super::process_tree::ManagedChild;
45
46const PRINT_SPONSOR_HASH_REVOKE_ERROR: &str = "--tempo.print-sponsor-hash only prints a sponsor hash and does not revoke the session on-chain";
47const SESSION_CHILD_SIGNER_ENV: &[&str] = &[
48 "ETH_KEYSTORE",
49 "ETH_KEYSTORE_ACCOUNT",
50 "ETH_PASSWORD",
51 "TEMPO_ACCESS_KEY",
52 "TEMPO_ROOT_ACCOUNT",
53];
54
55#[derive(Debug, Args)]
60#[command(args_conflicts_with_subcommands = true)]
61pub struct SessionArgs {
62 #[command(subcommand)]
63 pub command: Option<SessionSubcommands>,
64
65 #[arg(long)]
67 pub force: bool,
68
69 #[arg(long = "root", value_name = "ADDRESS")]
71 pub root_account: Option<Address>,
72
73 #[arg(long = "expires", id = "session_expires", value_name = "DURATION", value_parser = parse_period)]
75 pub expires: Option<u64>,
76
77 #[arg(long = "scope", value_parser = parse_scope)]
79 pub scope: Vec<CallScope>,
80
81 #[arg(long = "target", value_name = "ADDRESS")]
83 pub target: Option<Address>,
84
85 #[arg(long = "selector", value_name = "SELECTOR")]
87 pub selectors: Vec<String>,
88
89 #[arg(long = "spend-limit", value_parser = parse_spend_limit)]
91 pub spend_limits: Vec<SessionSpendLimit>,
92
93 #[arg(long = "for", value_name = "COMMAND")]
95 pub for_command: Option<String>,
96
97 #[command(flatten)]
98 pub tx: Box<TransactionOpts>,
99
100 #[command(flatten)]
101 pub send_tx: Box<SendTxOpts>,
102}
103
104impl SessionArgs {
105 pub async fn run(self) -> Result<()> {
106 let Self {
107 command,
108 force,
109 root_account,
110 expires,
111 scope,
112 target,
113 selectors,
114 spend_limits,
115 for_command,
116 tx,
117 send_tx,
118 } = self;
119
120 if let Some(command) = command {
121 return command.run().await;
122 }
123
124 let root_account =
125 root_account.ok_or_else(|| eyre::eyre!("cast wallet session requires --root"))?;
126 let expires =
127 expires.ok_or_else(|| eyre::eyre!("cast wallet session requires --expires"))?;
128 let command =
129 for_command.ok_or_else(|| eyre::eyre!("cast wallet session requires --for"))?;
130 let command = InnerCommand::parse(command)?;
131 let scope = session_scope(scope, target, selectors)?;
132 let send_tx = *send_tx;
133 let chain_id = resolve_session_chain_id(&send_tx).await?;
134
135 let tx = *tx;
136 if tx.tempo.print_sponsor_hash {
137 eyre::bail!(PRINT_SPONSOR_HASH_REVOKE_ERROR);
138 }
139
140 let entry = build_session_entry(
141 root_account,
142 chain_id,
143 expires,
144 scope,
145 spend_limits,
146 send_tx.eth.wallet.clone(),
147 )
148 .await?;
149 let session_id = entry.session_id;
150 upsert_session_entry(entry)?;
151
152 let child_result = command.run(session_id).await;
153
154 let retire_result = retire_session_entry(session_id)
157 .map(drop)
158 .wrap_err_with(|| format!("failed to retire local Tempo session {session_id:?}"));
159 let revoke_result =
160 revoke(session_id, false, tx, send_tx, UnprovisionedKeyPolicy::Fail, force).await;
161 let cleanup_result = match (retire_result, revoke_result) {
162 (Ok(()), result) | (result, Ok(())) => result,
163 (Err(retire_err), Err(revoke_err)) => Err(revoke_err
164 .wrap_err(format!("also failed to retire local Tempo session: {retire_err}"))),
165 };
166
167 match (child_result, cleanup_result) {
169 (Ok(()), Err(cleanup_err)) => {
170 Err(cleanup_err.wrap_err("failed to clean up Tempo session after inner command"))
171 }
172 (Err(child_err), Err(cleanup_err)) => Err(child_err.wrap_err(format!(
173 "also failed to clean up Tempo session {session_id:?}: {cleanup_err}"
174 ))),
175 (child_result, Ok(())) => child_result,
176 }
177 }
178}
179
180#[derive(Debug, Parser)]
182pub enum SessionSubcommands {
183 Create {
185 #[arg(long = "root", value_name = "ADDRESS")]
187 root_account: Address,
188
189 #[arg(long = "chain-id", value_name = "CHAIN_ID")]
191 chain_id: u64,
192
193 #[arg(long = "expires", value_name = "DURATION", value_parser = parse_period)]
195 expires: u64,
196
197 #[arg(long = "scope", value_parser = parse_scope, required = true)]
199 scope: Vec<CallScope>,
200
201 #[arg(long = "spend-limit", value_parser = parse_spend_limit)]
203 spend_limits: Vec<SessionSpendLimit>,
204
205 #[command(flatten)]
206 wallet: Box<WalletOpts>,
207 },
208
209 Revoke {
211 #[arg(value_name = "SESSION_ID")]
213 session_id: B256,
214
215 #[arg(long)]
217 local: bool,
218
219 #[arg(long)]
221 force: bool,
222
223 #[command(flatten)]
224 tx: Box<TransactionOpts>,
225
226 #[command(flatten)]
227 send_tx: Box<SendTxOpts>,
228 },
229}
230
231impl SessionSubcommands {
232 pub async fn run(self) -> Result<()> {
233 match self {
234 Self::Create { root_account, chain_id, expires, scope, spend_limits, wallet } => {
235 create(root_account, chain_id, expires, scope, spend_limits, *wallet).await
236 }
237 Self::Revoke { session_id, local, force, tx, send_tx } => {
238 revoke(
239 session_id,
240 local,
241 *tx,
242 *send_tx,
243 UnprovisionedKeyPolicy::RevokeLocally,
244 force,
245 )
246 .await
247 }
248 }
249 }
250}
251
252#[derive(Debug)]
253struct InnerCommand {
254 raw: String,
255 program: String,
256 args: Vec<String>,
257}
258
259impl InnerCommand {
260 fn parse(raw: String) -> Result<Self> {
261 let mut argv = split_for_command(&raw)?.into_iter();
262 let program = argv.next().ok_or_else(|| eyre::eyre!("--for command cannot be empty"))?;
263 let args = argv.collect();
264 Ok(Self { raw, program, args })
265 }
266
267 async fn run(&self, session_id: B256) -> Result<()> {
268 let mut interrupt = SessionInterrupt::new()?;
269 self.run_with_interrupt(session_id, interrupt.recv()).await
270 }
271
272 async fn run_with_interrupt<I>(&self, session_id: B256, interrupt: I) -> Result<()>
273 where
274 I: std::future::Future<Output = Result<&'static str>>,
275 {
276 let mut child = ManagedChild::spawn(self.command(session_id))
277 .wrap_err_with(|| format!("failed to run inner command `{}`", self.raw))?;
278
279 let status = tokio::select! {
280 status = child.wait() => status.wrap_err_with(|| {
281 format!("failed to wait for inner command `{}`", self.raw)
282 })?,
283 interrupt = interrupt => {
284 let _ = child.terminate_tree().await;
285 let interrupt = interrupt?;
286 eyre::bail!("inner command `{}` interrupted by {interrupt}", self.raw);
287 }
288 };
289
290 let _ = child.terminate_tree().await;
291
292 self.check_status(status)
293 }
294
295 fn command(&self, session_id: B256) -> Command {
296 let mut command = Command::new(&self.program);
297 command.args(&self.args);
298 for key in SESSION_CHILD_SIGNER_ENV {
299 command.env_remove(key);
300 }
301 command.env(TEMPO_SESSION_ID_ENV, format!("{session_id:?}"));
302 command
303 }
304
305 fn check_status(&self, status: ExitStatus) -> Result<()> {
306 if status.success() {
307 return Ok(());
308 }
309 match status.code() {
310 Some(code) => eyre::bail!("inner command `{}` exited with code {code}", self.raw),
311 None => eyre::bail!("inner command `{}` terminated by a signal", self.raw),
312 }
313 }
314}
315
316#[cfg(unix)]
317struct SessionInterrupt {
318 sigint: signal::unix::Signal,
319 sigterm: signal::unix::Signal,
320}
321
322#[cfg(unix)]
323impl SessionInterrupt {
324 fn new() -> Result<Self> {
325 Ok(Self {
326 sigint: signal::unix::signal(signal::unix::SignalKind::interrupt())
327 .wrap_err("failed to listen for SIGINT")?,
328 sigterm: signal::unix::signal(signal::unix::SignalKind::terminate())
329 .wrap_err("failed to listen for SIGTERM")?,
330 })
331 }
332
333 async fn recv(&mut self) -> Result<&'static str> {
334 tokio::select! {
335 _ = self.sigint.recv() => Ok("SIGINT"),
336 _ = self.sigterm.recv() => Ok("SIGTERM"),
337 }
338 }
339}
340
341#[cfg(not(unix))]
342struct SessionInterrupt;
343
344#[cfg(not(unix))]
345impl SessionInterrupt {
346 fn new() -> Result<Self> {
347 Ok(Self)
348 }
349
350 async fn recv(&mut self) -> Result<&'static str> {
351 signal::ctrl_c().await.wrap_err("failed to listen for Ctrl-C")?;
352 Ok("Ctrl-C")
353 }
354}
355
356async fn resolve_session_chain_id(send_tx: &SendTxOpts) -> Result<u64> {
357 let config = send_tx.eth.load_config()?;
358 if let Some(chain) = config.chain {
359 return Ok(chain.id());
360 }
361
362 let provider = ProviderBuilder::<TempoNetwork>::from_config(&config)?.build()?;
363 provider.get_chain_id().await.wrap_err(
364 "failed to resolve session chain id from RPC; pass --chain/--chain-id or --rpc-url",
365 )
366}
367
368fn session_scope(
369 mut scope: Vec<CallScope>,
370 target: Option<Address>,
371 selectors: Vec<String>,
372) -> Result<Vec<CallScope>> {
373 match target {
374 None if !selectors.is_empty() => eyre::bail!("--selector requires --target"),
375 Some(_) if selectors.is_empty() => eyre::bail!(
376 "--target requires at least one --selector; use --scope TARGET for target-wide access"
377 ),
378 Some(target) => {
379 let selector_rules = selectors
380 .iter()
381 .map(|selector| {
382 parse_selector_bytes(selector)
383 .map(|selector| SelectorRule { selector, recipients: vec![] })
384 .map_err(|err| eyre::eyre!("{err}"))
385 })
386 .collect::<Result<Vec<_>>>()?;
387 scope.push(CallScope { target, selector_rules });
388 }
389 None => {}
390 }
391
392 if scope.is_empty() {
393 eyre::bail!("cast wallet session requires --scope or --target");
394 }
395
396 Ok(scope)
397}
398
399fn split_for_command(command: &str) -> Result<Vec<String>> {
400 let mut args = Vec::new();
401 let mut current = String::new();
402 let mut quote = None;
403 let mut escaped = false;
404 let mut in_token = false;
405
406 for ch in command.chars() {
407 if escaped {
408 current.push(ch);
409 escaped = false;
410 in_token = true;
411 continue;
412 }
413
414 match quote {
415 Some('\'') => {
416 if ch == '\'' {
417 quote = None;
418 } else {
419 current.push(ch);
420 }
421 }
422 Some('"') => {
423 if ch == '"' {
424 quote = None;
425 } else if ch == '\\' {
426 escaped = true;
427 } else {
428 current.push(ch);
429 }
430 }
431 Some(_) => unreachable!(),
432 None if ch.is_whitespace() => {
433 if in_token {
434 args.push(std::mem::take(&mut current));
435 in_token = false;
436 }
437 }
438 None if ch == '\'' || ch == '"' => {
439 quote = Some(ch);
440 in_token = true;
441 }
442 None if ch == '\\' => {
443 escaped = true;
444 in_token = true;
445 }
446 None => {
447 current.push(ch);
448 in_token = true;
449 }
450 }
451 }
452
453 if escaped {
454 eyre::bail!("unterminated escape in --for command");
455 }
456 if let Some(quote) = quote {
457 eyre::bail!("unterminated {quote} quote in --for command");
458 }
459 if in_token {
460 args.push(current);
461 }
462 Ok(args)
463}
464
465async fn create(
467 root_account: Address,
468 chain_id: u64,
469 expires: u64,
470 scope: Vec<CallScope>,
471 spend_limits: Vec<SessionSpendLimit>,
472 wallet: WalletOpts,
473) -> Result<()> {
474 let entry =
475 build_session_entry(root_account, chain_id, expires, scope, spend_limits, wallet).await?;
476 let json = json!({
477 "session_id": entry.session_id.to_string(),
478 "root_account": entry.root_account.to_string(),
479 "chain_id": entry.chain_id,
480 "key_address": entry.key_address.to_string(),
481 "expiry": entry.expiry,
482 "status": "active",
483 "scope_count": entry.scope.as_ref().map_or(0, Vec::len),
484 "spend_limit_count": entry.limits.as_ref().map_or(0, Vec::len),
485 });
486 let prose = format!(
487 "Created Tempo session {}\nRoot: {}\nChain: {}\nKey: {}\nExpiry: {}",
488 entry.session_id, entry.root_account, entry.chain_id, entry.key_address, entry.expiry
489 );
490 upsert_session_entry(entry)?;
491
492 print_json_or(json, prose)
493}
494
495#[derive(Clone, Copy, Debug, PartialEq, Eq)]
497enum UnprovisionedKeyPolicy {
498 RevokeLocally,
500 Fail,
502}
503
504async fn revoke(
506 session_id: B256,
507 local: bool,
508 tx: TransactionOpts,
509 send_tx: SendTxOpts,
510 unprovisioned_policy: UnprovisionedKeyPolicy,
511 force: bool,
512) -> Result<()> {
513 let Some(entry) = read_session_entry(session_id)? else {
514 return print_revoke_status(session_id, None, SessionRevokeStatus::NotFound);
515 };
516
517 if local {
518 retire_session_entry(session_id)?;
519 return print_revoke_status(session_id, Some(&entry), SessionRevokeStatus::Local);
520 }
521
522 if tx.tempo.print_sponsor_hash {
523 eyre::bail!(PRINT_SPONSOR_HASH_REVOKE_ERROR);
524 }
525
526 let (_, provider) = tempo::tempo_provider(&send_tx.eth)?;
527 let rpc_chain_id = provider.get_chain_id().await?;
528 if rpc_chain_id != entry.chain_id {
529 eyre::bail!(
530 "session {} was created for chain {}, but the RPC is connected to chain {}",
531 entry.session_id,
532 entry.chain_id,
533 rpc_chain_id
534 );
535 }
536
537 let info = provider.get_keychain_key(entry.root_account, entry.key_address).await?;
538 if info.isRevoked {
539 retire_session_entry(session_id)?;
540 return print_revoke_status(session_id, Some(&entry), SessionRevokeStatus::AlreadyRevoked);
541 }
542 if info.keyId == Address::ZERO {
543 return match unprovisioned_policy {
544 UnprovisionedKeyPolicy::RevokeLocally => {
545 retire_session_entry(session_id)?;
546 print_revoke_status(session_id, Some(&entry), SessionRevokeStatus::NotProvisioned)
547 }
548 UnprovisionedKeyPolicy::Fail => eyre::bail!(
549 "session key is not provisioned on-chain yet; pending transactions from the \
550 wrapped command may still provision it. Wait for pending transactions to settle, \
551 then run `cast wallet session revoke {session_id}`."
552 ),
553 };
554 }
555
556 let root_signer =
557 resolve_keychain_root_signer(&send_tx, Some(entry.root_account), false).await?;
558 let calldata = IAccountKeychain::revokeKeyCall { keyId: entry.key_address }.abi_encode();
559 let outcome =
560 send_keychain_tx_with_root_signer(calldata, tx, &send_tx, root_signer, force, || {
561 retire_session_entry(session_id).map(drop)
562 })
563 .await
564 .and_then(|outcome| {
565 if outcome == KeychainTxOutcome::PrintedSponsorHash {
566 eyre::bail!(PRINT_SPONSOR_HASH_REVOKE_ERROR);
567 }
568 Ok(outcome)
569 });
570 let outcome = match outcome {
571 Ok(outcome) => outcome,
572 Err(err) => {
573 if provider
575 .get_keychain_key(entry.root_account, entry.key_address)
576 .await
577 .is_ok_and(|info| info.isRevoked)
578 {
579 let _ = retire_session_entry(session_id);
580 }
581 return Err(err.wrap_err("failed to revoke Tempo session key on-chain"));
582 }
583 };
584
585 if outcome == KeychainTxOutcome::Aborted {
586 if unprovisioned_policy == UnprovisionedKeyPolicy::Fail {
588 eyre::bail!("EIP-7702 authorization disclosure was declined");
589 }
590 return Ok(());
591 }
592
593 retire_session_entry(session_id)?;
594 Ok(())
595}
596
597#[derive(Clone, Copy, Debug, PartialEq, Eq)]
598enum SessionRevokeStatus {
599 NotFound,
600 Local,
601 NotProvisioned,
602 AlreadyRevoked,
603}
604
605impl SessionRevokeStatus {
606 const fn reason(self) -> &'static str {
607 match self {
608 Self::NotFound => "not_found",
609 Self::Local => "local",
610 Self::NotProvisioned => "not_provisioned",
611 Self::AlreadyRevoked => "already_revoked",
612 }
613 }
614}
615
616fn print_revoke_status(
617 session_id: B256,
618 entry: Option<&SessionEntry>,
619 status: SessionRevokeStatus,
620) -> Result<()> {
621 if shell::is_json() {
622 return sh_println!(
623 "{}",
624 serde_json::to_string_pretty(&json!({
625 "session_id": session_id.to_string(),
626 "status": if status == SessionRevokeStatus::NotFound { "not_found" } else { "revoked" },
627 "reason": status.reason(),
628 "root_account": entry.map(|entry| entry.root_account.to_string()),
629 "chain_id": entry.map(|entry| entry.chain_id),
630 "key_address": entry.map(|entry| entry.key_address.to_string()),
631 }))?
632 );
633 }
634
635 match status {
636 SessionRevokeStatus::NotFound => sh_status!("Tempo session {session_id} was not found."),
637 SessionRevokeStatus::Local => sh_status!("Revoked local Tempo session {session_id}"),
638 SessionRevokeStatus::NotProvisioned => sh_status!(
639 "Revoked Tempo session {session_id} locally; key was not provisioned on-chain"
640 ),
641 SessionRevokeStatus::AlreadyRevoked => sh_status!(
642 "Revoked Tempo session {session_id} locally; key was already revoked on-chain"
643 ),
644 }
645}
646
647async fn build_session_entry(
649 root_account: Address,
650 chain_id: u64,
651 expires: u64,
652 scope: Vec<CallScope>,
653 spend_limits: Vec<SessionSpendLimit>,
654 wallet: WalletOpts,
655) -> Result<SessionEntry> {
656 if expires == 0 {
657 eyre::bail!("--expires must be greater than 0");
658 }
659 if chain_id == 0 {
660 eyre::bail!("--chain-id must be greater than 0");
661 }
662 if wallet.from.is_some_and(|from| from != root_account) {
663 eyre::bail!("--from must match --root for cast wallet session create");
664 }
665
666 let signer = resolve_root_signer(wallet, root_account, chain_id).await?;
667 let session_key = GeneratedSessionKey::random();
668 let session_id = B256::random();
669 let now_secs = now().as_secs();
670 let expiry = now_secs
671 .checked_add(expires)
672 .ok_or_else(|| eyre::eyre!("session expiry overflows the unix timestamp range"))?;
673 let expiry =
674 NonZeroU64::new(expiry).ok_or_else(|| eyre::eyre!("session expiry cannot be zero"))?;
675
676 let request = SessionAuthorizationRequest {
677 session_id,
678 root_account,
679 chain_id,
680 key_address: session_key.address(),
681 expiry,
682 scope,
683 spend_limits,
684 };
685 let prepared = request.prepare(now_secs)?;
686 let signature = signer.sign_hash(&prepared.authorization.signature_hash()).await?;
687 let signed_authorization =
688 prepared.authorization.clone().into_signed(PrimitiveSignature::Secp256k1(signature));
689 prepared.into_active_entry(session_key, &signed_authorization)
690}
691
692async fn resolve_root_signer(
693 wallet: WalletOpts,
694 root_account: Address,
695 chain_id: u64,
696) -> Result<WalletSigner> {
697 let (signer, tempo_access_key) = wallet.maybe_signer_for_chain(chain_id).await?;
698 if tempo_access_key.is_some() {
699 eyre::bail!(
700 "Tempo access keys cannot authorize Tempo sessions; use a persistent root signer"
701 );
702 }
703
704 let signer = signer.ok_or_else(|| eyre::eyre!("a root wallet signer is required"))?;
705 let signer_address = signer.address();
706 if signer_address != root_account {
707 eyre::bail!("resolved signer {} does not match --root {}", signer_address, root_account);
708 }
709
710 Ok(signer)
711}
712
713fn parse_scope(s: &str) -> Result<CallScope, String> {
715 parse_policy_scope(s).map(CallScope::from)
716}
717
718fn parse_spend_limit(s: &str) -> Result<SessionSpendLimit, String> {
720 let Some((token_str, amount_str)) = s.split_once(':').or_else(|| s.split_once('=')) else {
721 return Err(format!("invalid limit format: {s} (expected TOKEN:AMOUNT or TOKEN=AMOUNT)"));
722 };
723
724 let token = parse_fee_token_address(token_str.trim()).map_err(|e| e.to_string())?;
725 let amount: U256 =
726 amount_str.trim().parse().map_err(|e| format!("invalid amount '{amount_str}': {e}"))?;
727 Ok(SessionSpendLimit { token, amount })
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733 use alloy_primitives::address;
734 use foundry_common::tempo::SessionStatus;
735 use std::{ffi::OsStr, sync::Mutex};
736 use tempo_contracts::precompiles::PATH_USD_ADDRESS;
737
738 const ROOT_PRIVATE_KEY: &str =
739 "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
740
741 static ENV_MUTEX: Mutex<()> = Mutex::new(());
742
743 fn with_tempo_home(test: impl FnOnce()) {
744 let _guard = ENV_MUTEX.lock().unwrap();
745 let tmp = tempfile::tempdir().unwrap();
746 unsafe { std::env::set_var("TEMPO_HOME", tmp.path()) };
748 test();
749 unsafe { std::env::remove_var("TEMPO_HOME") };
751 }
752
753 #[test]
754 fn parse_spend_limit_accepts_fee_token_symbol() {
755 let limit = parse_spend_limit("PathUSD=0").unwrap();
756 assert_eq!(limit.token, PATH_USD_ADDRESS);
757 assert_eq!(limit.amount, U256::ZERO);
758 }
759
760 #[test]
761 fn inner_command_parse_preserves_literal_argv() {
762 let raw =
763 r#"forge script "Deploy Script" --sig 'run(uint256)' value\ with\ spaces #literal"#;
764 let command = InnerCommand::parse(raw.to_string()).unwrap();
765
766 assert_eq!(command.raw, raw);
767 assert_eq!(command.program, "forge");
768 assert_eq!(
769 command.args,
770 ["script", "Deploy Script", "--sig", "run(uint256)", "value with spaces", "#literal",]
771 );
772 }
773
774 #[test]
775 fn inner_command_parse_rejects_invalid_input() {
776 let err = InnerCommand::parse(" ".to_string()).unwrap_err();
777 assert!(err.to_string().contains("--for command cannot be empty"), "{err}");
778
779 let err = InnerCommand::parse("forge 'script".to_string()).unwrap_err();
780 assert!(err.to_string().contains("unterminated"), "{err}");
781 }
782
783 #[test]
784 fn session_scope_target_shortcut() {
785 let target = address!("0x00000000000000000000000000000000000000aa");
786 let err = session_scope(vec![], Some(target), vec![]).unwrap_err();
787 assert!(err.to_string().contains("--target requires at least one --selector"), "{err}");
788
789 let scope = vec![CallScope { target, selector_rules: vec![] }];
791 assert_eq!(session_scope(scope.clone(), None, vec![]).unwrap(), scope);
792 }
793
794 #[test]
795 fn inner_command_clears_inherited_signer_env_for_session_child() {
796 let session_id = B256::from([0x7a; 32]);
797 let command = InnerCommand::parse("forge script Deploy".to_string()).unwrap();
798 let child = command.command(session_id);
799
800 for key in SESSION_CHILD_SIGNER_ENV {
801 assert_eq!(
802 command_env(&child, key),
803 Some(None),
804 "expected {key} to be removed from session child environment"
805 );
806 }
807
808 let expected_session_id = format!("{session_id:?}");
809 assert_eq!(
810 command_env(&child, TEMPO_SESSION_ID_ENV),
811 Some(Some(OsStr::new(&expected_session_id)))
812 );
813 assert_eq!(
814 command_env(&child, "ETH_FROM"),
815 None,
816 "ETH_FROM is a sender hint and should not be stripped by session --for"
817 );
818 }
819
820 #[cfg(unix)]
821 #[test]
822 fn inner_command_interrupt_terminates_child() {
823 let runtime = tokio::runtime::Runtime::new().unwrap();
824 runtime.block_on(async {
825 let session_id = B256::from([0x7b; 32]);
826 let command = InnerCommand::parse("sh -c 'sleep 30'".to_string()).unwrap();
827 let err = command
828 .run_with_interrupt(session_id, std::future::ready(Ok("test interrupt")))
829 .await
830 .unwrap_err();
831
832 assert!(err.to_string().contains("interrupted by test interrupt"), "{err}");
833 });
834 }
835
836 fn command_env<'a>(command: &'a Command, key: &str) -> Option<Option<&'a OsStr>> {
837 command.get_envs().find_map(|(name, value)| (name == key).then_some(value))
838 }
839
840 #[test]
841 fn local_revoke_is_idempotent_when_missing() {
842 with_tempo_home(|| {
843 assert!(!retire_session_entry(B256::from([0x42; 32])).unwrap());
844 });
845 }
846
847 #[test]
848 fn create_and_local_revoke_session_entry_round_trips() {
849 with_tempo_home(|| {
850 let runtime = tokio::runtime::Runtime::new().unwrap();
851 runtime.block_on(async {
852 let root = address!("0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266");
853 let wallet = WalletOpts {
854 raw: foundry_wallets::RawWalletOpts {
855 private_key: Some(ROOT_PRIVATE_KEY.to_string()),
856 ..Default::default()
857 },
858 ..Default::default()
859 };
860
861 let entry = build_session_entry(
862 root,
863 4217,
864 600,
865 vec![CallScope {
866 target: address!("0x00000000000000000000000000000000000000aa"),
867 selector_rules: vec![],
868 }],
869 vec![],
870 wallet,
871 )
872 .await
873 .unwrap();
874 assert_eq!(entry.status, SessionStatus::Active);
875 assert!(entry.key.is_some());
876
877 let session_id = entry.session_id;
878 let expiry = entry.expiry;
879 upsert_session_entry(entry).unwrap();
880 let stored = read_session_entry(session_id).unwrap().unwrap();
881 assert_eq!(stored.session_id, session_id);
882 assert!(stored.has_live_key_at(expiry - 1));
883
884 assert!(retire_session_entry(session_id).unwrap());
885 let session = read_session_entry(session_id).unwrap().unwrap();
886 assert_eq!(session.status, SessionStatus::Revoked);
887 assert!(session.key.is_none());
888 });
889 });
890 }
891}