Skip to content

Commit c5e287e

Browse files
authored
fix: env blocks not setting variable for SSH script and terminal blocks (#377)
1 parent af15d97 commit c5e287e

File tree

5 files changed

+124
-3
lines changed

5 files changed

+124
-3
lines changed

crates/atuin-desktop-runtime/src/blocks/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ pub(crate) mod script;
2727
pub(crate) mod sql_block;
2828
pub(crate) mod sqlite;
2929
pub(crate) mod ssh_connect;
30+
3031
pub(crate) mod sub_runbook;
3132
pub(crate) mod terminal;
3233
pub(crate) mod var;

crates/atuin-desktop-runtime/src/blocks/script.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use crate::events::GCEvent;
1717
use crate::execution::{
1818
CancellationToken, ExecutionContext, ExecutionHandle, ExecutionStatus, StreamingBlockOutput,
1919
};
20-
use crate::ssh::{OutputLine as SessionOutputLine, SshWarning};
20+
use crate::ssh::{build_env_exports, OutputLine as SessionOutputLine, SshWarning};
2121

2222
use super::FromDocument;
2323

@@ -697,8 +697,15 @@ impl Script {
697697
None
698698
};
699699

700+
let env_exports = build_env_exports(context.context_resolver.env_vars());
701+
700702
let code_to_run = if let Some(ref path) = remote_temp_path {
701-
format!("export ATUIN_OUTPUT_VARS='{}'\n{}", path, code)
703+
format!(
704+
"{}export ATUIN_OUTPUT_VARS='{}'\n{}",
705+
env_exports, path, code
706+
)
707+
} else if !env_exports.is_empty() {
708+
format!("{}{}", env_exports, code)
702709
} else {
703710
code.to_string()
704711
};

crates/atuin-desktop-runtime/src/blocks/terminal.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ use crate::execution::{
1414
CancellationToken, ExecutionContext, ExecutionHandle, ExecutionStatus, StreamingBlockOutput,
1515
};
1616
use crate::pty::{Pty, PtyLike};
17-
use crate::ssh::{SshPty, SshWarning};
17+
use crate::ssh::{build_env_exports, SshPty, SshWarning};
1818

1919
/// Output structure for Terminal blocks that implements BlockExecutionOutput
2020
/// for template access to terminal output.
@@ -491,6 +491,15 @@ impl Terminal {
491491
.emit_gc_event(GCEvent::PtyOpened(metadata.clone()))
492492
.await;
493493

494+
if ssh_host.is_some() {
495+
let env_exports = build_env_exports(context.context_resolver.env_vars());
496+
if !env_exports.is_empty() {
497+
if let Err(e) = pty_store.write_pty(self.id, env_exports.into()).await {
498+
tracing::warn!("Failed to write env exports to SSH PTY: {}", e);
499+
}
500+
}
501+
}
502+
494503
if let Some(ref remote_path) = remote_var_path {
495504
let export_cmd = format!("export ATUIN_OUTPUT_VARS='{}'\n", remote_path);
496505
if let Err(e) = pty_store.write_pty(self.id, export_cmd.into()).await {

crates/atuin-desktop-runtime/src/ssh/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,13 @@
2222
2323
mod pool;
2424
mod session;
25+
mod ssh_env;
2526
mod ssh_pool;
2627

2728
#[cfg(test)]
2829
mod integration_tests;
2930

3031
pub use pool::Pool;
3132
pub use session::{Authentication, CommandResult, OutputLine, Session, SshConfig, SshWarning};
33+
pub use ssh_env::build_env_exports;
3234
pub use ssh_pool::{SshPoolHandle, SshPty};
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
use std::collections::HashMap;
2+
3+
/// Escape a value for safe inclusion in a POSIX single-quoted string.
4+
///
5+
/// Replaces each `'` with `'\''` (end quote, escaped literal quote, start new quote)
6+
/// and wraps the result in single quotes. POSIX single-quoted strings treat all
7+
/// characters as literal, so this handles `$`, backticks, newlines, etc.
8+
fn shell_escape_value(value: &str) -> String {
9+
let mut escaped = String::with_capacity(value.len() + 2);
10+
escaped.push('\'');
11+
for ch in value.chars() {
12+
if ch == '\'' {
13+
escaped.push_str("'\\''");
14+
} else {
15+
escaped.push(ch);
16+
}
17+
}
18+
escaped.push('\'');
19+
escaped
20+
}
21+
22+
/// Build `export K='V'\n` lines for all env vars.
23+
///
24+
/// Returns an empty string if the map is empty.
25+
pub fn build_env_exports(env_vars: &HashMap<String, String>) -> String {
26+
if env_vars.is_empty() {
27+
return String::new();
28+
}
29+
30+
let mut out = String::new();
31+
for (key, value) in env_vars {
32+
out.push_str("export ");
33+
out.push_str(key);
34+
out.push('=');
35+
out.push_str(&shell_escape_value(value));
36+
out.push('\n');
37+
}
38+
out
39+
}
40+
41+
#[cfg(test)]
42+
mod tests {
43+
use super::*;
44+
45+
#[test]
46+
fn test_shell_escape_simple_value() {
47+
assert_eq!(shell_escape_value("hello"), "'hello'");
48+
}
49+
50+
#[test]
51+
fn test_shell_escape_empty_value() {
52+
assert_eq!(shell_escape_value(""), "''");
53+
}
54+
55+
#[test]
56+
fn test_shell_escape_value_with_single_quotes() {
57+
assert_eq!(shell_escape_value("it's"), "'it'\\''s'");
58+
}
59+
60+
#[test]
61+
fn test_shell_escape_value_with_special_chars() {
62+
assert_eq!(shell_escape_value("$HOME"), "'$HOME'");
63+
assert_eq!(shell_escape_value("`cmd`"), "'`cmd`'");
64+
assert_eq!(shell_escape_value("a\nb"), "'a\nb'");
65+
assert_eq!(shell_escape_value("a b"), "'a b'");
66+
assert_eq!(shell_escape_value("a\"b"), "'a\"b'");
67+
}
68+
69+
#[test]
70+
fn test_build_env_exports_empty_map() {
71+
let map = HashMap::new();
72+
assert_eq!(build_env_exports(&map), "");
73+
}
74+
75+
#[test]
76+
fn test_build_env_exports_single_var() {
77+
let mut map = HashMap::new();
78+
map.insert("FOO".to_string(), "bar".to_string());
79+
assert_eq!(build_env_exports(&map), "export FOO='bar'\n");
80+
}
81+
82+
#[test]
83+
fn test_build_env_exports_special_value() {
84+
let mut map = HashMap::new();
85+
map.insert("VAR".to_string(), "it's $complex".to_string());
86+
assert_eq!(build_env_exports(&map), "export VAR='it'\\''s $complex'\n");
87+
}
88+
89+
#[test]
90+
fn test_build_env_exports_multiple_vars() {
91+
let mut map = HashMap::new();
92+
map.insert("A".to_string(), "1".to_string());
93+
map.insert("B".to_string(), "2".to_string());
94+
95+
let result = build_env_exports(&map);
96+
// HashMap order is not guaranteed, so check both lines are present
97+
assert!(result.contains("export A='1'\n"));
98+
assert!(result.contains("export B='2'\n"));
99+
// Should have exactly 2 lines
100+
assert_eq!(result.lines().count(), 2);
101+
}
102+
}

0 commit comments

Comments
 (0)