Skip to content

Commit e1fb6be

Browse files
author
Test User
committed
Merge remote-tracking branch 'gitea/feat/rlm-run-subcommand-2026-06-29' into HEAD
2 parents 8e28ea7 + 31e7c08 commit e1fb6be

3 files changed

Lines changed: 129 additions & 7 deletions

File tree

Cargo.lock

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

crates/terraphim_rlm/Cargo.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,13 @@ rmcp = { version = "0.9.0", features = ["server"], optional = true }
6060
# Utilities
6161
parking_lot = "0.12"
6262
dashmap = "6.1"
63+
# Used in auto_configure_llm to bound the 1Password CLI call so that
64+
# non-tty contexts (CI, agent shells) don't hang on biometric approval
65+
# that can never arrive. 5s is generous; op read is normally <500ms.
66+
wait-timeout = "0.2"
67+
# Used in auto_configure_llm to skip the 1Password lookup entirely in
68+
# non-interactive contexts where it can never succeed.
69+
atty = "0.2"
6370

6471
# CLI
6572
clap = { version = "4.5", features = ["derive"], optional = true }

crates/terraphim_rlm/src/rlm.rs

Lines changed: 100 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -921,20 +921,113 @@ impl TerraphimRlm {
921921
);
922922
log::info!("RLM auto-configure: ollama model={}", ollama_model);
923923

924-
// OpenRouter: API key from env or 1Password, free tier model
924+
// OpenRouter: API key from env or 1Password, free tier model.
925+
//
926+
// Two-stage guard against the 1Password-hang trap that affected non-tty
927+
// shells (agent contexts, CI, `... | terraphim_rlm`):
928+
// 1. Skip entirely if stdin/stderr aren't ttys AND no GUI display is
929+
// available. 1Password's desktop daemon cannot prompt for biometric
930+
// approval in such contexts, so the call would block forever.
931+
// 2. If we do try, bound the wait with `wait_timeout` (5s, generous —
932+
// `op read` is normally <500ms) so a hung daemon doesn't hang us.
933+
//
934+
// Set `RLM_OP_TIMEOUT_SECS=0` to disable the timeout (debugging only).
935+
// Set `RLM_SKIP_OP=1` to force-skip the 1Password lookup.
925936
let or_api_key = std::env::var("OPENROUTER_API_KEY").ok().or_else(|| {
926-
let output = std::process::Command::new("op")
937+
if std::env::var("RLM_SKIP_OP").ok().as_deref() == Some("1") {
938+
log::debug!("RLM auto-configure: RLM_SKIP_OP=1 set, skipping 1Password");
939+
return None;
940+
}
941+
let stdin_is_tty = atty::is(atty::Stream::Stdin);
942+
let stderr_is_tty = atty::is(atty::Stream::Stderr);
943+
let has_display = std::env::var("DISPLAY").is_ok()
944+
|| std::env::var("WAYLAND_DISPLAY").is_ok();
945+
if (!stdin_is_tty || !stderr_is_tty) && !has_display {
946+
log::debug!(
947+
"RLM auto-configure: skipping 1Password (no tty, no display): \
948+
stdin_tty={}, stderr_tty={}, has_display={}",
949+
stdin_is_tty, stderr_is_tty, has_display
950+
);
951+
return None;
952+
}
953+
use std::io::Read;
954+
use std::process::Stdio;
955+
use std::time::Duration;
956+
use wait_timeout::ChildExt;
957+
958+
let timeout_secs: u64 = std::env::var("RLM_OP_TIMEOUT_SECS")
959+
.ok()
960+
.and_then(|s| s.parse().ok())
961+
.unwrap_or(5);
962+
if timeout_secs == 0 {
963+
log::debug!("RLM auto-configure: RLM_OP_TIMEOUT_SECS=0, no timeout");
964+
}
965+
966+
let mut child = match std::process::Command::new("op")
927967
.args([
928968
"read",
929969
"--account",
930970
"my.1password.com",
931971
"op://Terraphim/OpenRouterTesting-api-key/password",
932972
])
933-
.output()
934-
.ok()?;
935-
String::from_utf8(output.stdout)
936-
.ok()
937-
.map(|s| s.trim().to_string())
973+
.stdin(Stdio::null())
974+
.stdout(Stdio::piped())
975+
.stderr(Stdio::piped())
976+
.spawn()
977+
{
978+
Ok(c) => c,
979+
Err(e) => {
980+
log::warn!("RLM auto-configure: failed to spawn `op`: {}", e);
981+
return None;
982+
}
983+
};
984+
985+
// Returns Some(ExitStatus) on completion, None on timeout.
986+
// wait_timeout returns Result<Option<ExitStatus>, io::Error>;
987+
// the inner Option distinguishes "still running" (Some(None)) from
988+
// "errored" (Err). We collapse all "no usable status" cases to None.
989+
// Plain wait() returns Result<ExitStatus, io::Error>; lift to Option.
990+
let status_result: Option<std::process::ExitStatus> = if timeout_secs == 0 {
991+
child.wait().ok()
992+
} else {
993+
match child.wait_timeout(Duration::from_secs(timeout_secs)) {
994+
Ok(Some(status)) => Some(status),
995+
Ok(None) => {
996+
// Shouldn't happen with our usage but handle defensively
997+
log::warn!("RLM auto-configure: wait_timeout returned no status");
998+
let _ = child.kill();
999+
let _ = child.wait();
1000+
None
1001+
}
1002+
Err(e) => {
1003+
log::warn!("RLM auto-configure: wait_timeout error: {}", e);
1004+
let _ = child.kill();
1005+
None
1006+
}
1007+
}
1008+
};
1009+
match status_result {
1010+
Some(status) if status.success() => {
1011+
let mut s = String::new();
1012+
if let Some(mut stdout) = child.stdout.take() {
1013+
let _ = stdout.read_to_string(&mut s);
1014+
}
1015+
Some(s.trim().to_string())
1016+
}
1017+
Some(_) => {
1018+
log::warn!("RLM auto-configure: `op read` exited non-zero");
1019+
None
1020+
}
1021+
None => {
1022+
log::warn!(
1023+
"RLM auto-configure: `op read` exceeded {}s timeout, killing",
1024+
timeout_secs
1025+
);
1026+
let _ = child.kill();
1027+
let _ = child.wait();
1028+
None
1029+
}
1030+
}
9381031
});
9391032

9401033
if let Some(ref key) = or_api_key {

0 commit comments

Comments
 (0)