Add optional command extensions

This commit is contained in:
DuProcess
2026-06-19 16:17:38 -04:00
parent 90e53f4b68
commit 41cdb0f54f
5 changed files with 241 additions and 8 deletions
+23
View File
@@ -165,6 +165,29 @@ in Dosh's host config:
dosh import-ssh palav homelab
```
Optional command extensions are just config-side startup shortcuts; Dosh has no
compile-time dependency on the tools they run. For example, to make a separately
installed server-side `tm` dashboard easy to open:
```toml
# ~/.config/dosh/client.toml
[extensions.tm]
command = "tm {args}"
description = "Open the server-side tmux dashboard"
```
Then `dosh palav tm` sends `tm`, and `dosh palav tm dosh` sends `tm 'dosh'`.
Remove that table to remove the integration. Hosts can override or opt out:
```toml
# ~/.config/dosh/hosts.toml
[palav.extensions.tm]
command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm]
disabled = true
```
## Develop
Build:
+7
View File
@@ -51,6 +51,7 @@ dosh -L 8080:127.0.0.1:80 host
dosh -R 9000:127.0.0.1:9000 host
dosh --session work host
dosh --view-only --session work host
dosh host tm
```
Compatibility expectations:
@@ -107,6 +108,8 @@ Must work in v1:
- `dosh update`.
- `dosh doctor host` for config/auth/UDP reachability diagnostics.
- `dosh sessions host` for session visibility.
- Optional command extensions such as `dosh host tm` for companion tools that are
installed separately from Dosh.
Should work in v1 if it does not compromise the transport schedule:
@@ -556,6 +559,10 @@ forward_agent = false
send_env = ["LANG", "LC_*", "TERM", "COLORTERM"]
set_env = {}
forwardings = []
[extensions.tm]
command = "tm {args}"
description = "Open an optional server-side tmux dashboard"
```
Server:
+23
View File
@@ -101,6 +101,29 @@ dosh import-ssh palav homelab
This appends entries to `~/.config/dosh/hosts.toml` without trying to become an
OpenSSH config parser.
## Optional Command Extensions
Dosh can expose companion tools without taking a dependency on them. Command
extensions live in client or host config and expand only the first trailing word:
```toml
[extensions.tm]
command = "tm {args}"
description = "Open the server-side tmux dashboard"
```
With that config, `dosh palav tm` runs `tm` in the remote Dosh shell and
`dosh palav tm dosh` runs `tm 'dosh'`. Removing the table removes the integration.
Host config can override a global extension, or disable it:
```toml
[palav.extensions.tm]
command = "/opt/tm/bin/tm {args}"
[other-host.extensions.tm]
disabled = true
```
## Forwarding (Implemented)
SSH forwarding cannot be copied by keeping the bootstrap SSH connection open,
+166 -8
View File
@@ -212,11 +212,7 @@ async fn main() -> Result<()> {
.unwrap_or_default();
let raw_server = host.ssh.clone().unwrap_or_else(|| requested_server.clone());
let server = ssh_with_user(&raw_server, host.user.as_deref());
let startup_command = startup_command(&args.command).or_else(|| {
host.default_command
.as_deref()
.map(startup_command_from_string)
});
let startup_command = resolved_startup_command(&args.command, &config, &host);
let local_forwards = parse_local_forwards(&args.local_forward)?;
let remote_forwards = parse_remote_forwards(&args.remote_forward)?;
let dynamic_forwards = parse_dynamic_forwards(&args.dynamic_forward)?;
@@ -776,6 +772,73 @@ fn startup_command(args: &[String]) -> Option<Vec<u8>> {
Some(command.into_bytes())
}
fn resolved_startup_command(
args: &[String],
config: &dosh::config::ClientConfig,
host: &dosh::config::HostConfig,
) -> Option<Vec<u8>> {
if args.is_empty() {
return host
.default_command
.as_deref()
.map(startup_command_from_string);
}
match resolve_command_extension(args, config, host) {
ExtensionResolution::Command(command) => Some(startup_command_from_string(&command)),
ExtensionResolution::Disabled => None,
ExtensionResolution::NoMatch => startup_command(args),
}
}
enum ExtensionResolution {
Command(String),
Disabled,
NoMatch,
}
fn resolve_command_extension(
args: &[String],
config: &dosh::config::ClientConfig,
host: &dosh::config::HostConfig,
) -> ExtensionResolution {
let Some(name) = args.first() else {
return ExtensionResolution::NoMatch;
};
if let Some(extension) = host.extensions.get(name) {
if extension.disabled {
return ExtensionResolution::Disabled;
}
if let Some(command) = extension.command.as_deref() {
return ExtensionResolution::Command(expand_command_extension(command, &args[1..]));
}
}
if let Some(extension) = config.extensions.get(name) {
if extension.disabled {
return ExtensionResolution::Disabled;
}
if let Some(command) = extension.command.as_deref() {
return ExtensionResolution::Command(expand_command_extension(command, &args[1..]));
}
}
ExtensionResolution::NoMatch
}
fn expand_command_extension(template: &str, args: &[String]) -> String {
let quoted_args = args
.iter()
.map(|arg| shell_word(arg))
.collect::<Vec<_>>()
.join(" ");
if template.contains("{args}") {
return template.replace("{args}", &quoted_args);
}
if args.is_empty() {
template.to_string()
} else {
format!("{template} {quoted_args}")
}
}
fn startup_command_from_string(command: &str) -> Vec<u8> {
let mut command = command.to_string();
command.push('\n');
@@ -4226,10 +4289,11 @@ mod tests {
RemoteForward, SshConfig, StatusAction, auth_allows,
load_first_native_identity_with_prompt, parse_dynamic_forward, parse_local_forward,
parse_remote_forward, parse_ssh_config, raw_contains_host_table, recv_response_until,
render_status_clear, render_status_overlay, requested_env, ssh_destination_host,
ssh_username, ssh_with_user, startup_command, toml_bare_key_or_quoted,
render_status_clear, render_status_overlay, requested_env, resolved_startup_command,
ssh_destination_host, ssh_username, ssh_with_user, startup_command,
toml_bare_key_or_quoted,
};
use dosh::config::{ClientConfig, HostConfig};
use dosh::config::{ClientConfig, CommandExtension, HostConfig};
use dosh::native::EnvVar;
use dosh::protocol::{self, Frame, PacketKind};
use ed25519_dalek::{SigningKey, VerifyingKey};
@@ -4775,6 +4839,100 @@ mod tests {
);
}
#[test]
fn resolved_startup_command_expands_global_extension() {
let mut config = ClientConfig::default();
config.extensions.insert(
"tm".to_string(),
CommandExtension {
command: Some("tm {args}".to_string()),
description: Some("server tmux dashboard".to_string()),
disabled: false,
},
);
let host = HostConfig::default();
assert_eq!(
resolved_startup_command(&["tm".into(), "work space".into()], &config, &host),
Some(b"tm 'work space'\n".to_vec())
);
}
#[test]
fn resolved_startup_command_uses_host_extension_override() {
let mut config = ClientConfig::default();
config.extensions.insert(
"tm".to_string(),
CommandExtension {
command: Some("tm {args}".to_string()),
description: None,
disabled: false,
},
);
let mut host = HostConfig::default();
host.extensions.insert(
"tm".to_string(),
CommandExtension {
command: Some("/opt/tm/bin/tm --remote {args}".to_string()),
description: None,
disabled: false,
},
);
assert_eq!(
resolved_startup_command(&["tm".into(), "dosh".into()], &config, &host),
Some(b"/opt/tm/bin/tm --remote 'dosh'\n".to_vec())
);
}
#[test]
fn resolved_startup_command_host_can_disable_global_extension() {
let mut config = ClientConfig::default();
config.extensions.insert(
"tm".to_string(),
CommandExtension {
command: Some("tm {args}".to_string()),
description: None,
disabled: false,
},
);
let mut host = HostConfig::default();
host.extensions.insert(
"tm".to_string(),
CommandExtension {
command: None,
description: None,
disabled: true,
},
);
assert_eq!(
resolved_startup_command(&["tm".into(), "dosh".into()], &config, &host),
None
);
}
#[test]
fn resolved_startup_command_keeps_raw_command_fallback_and_default_command() {
let config = ClientConfig::default();
let mut host = HostConfig {
default_command: Some("tm".to_string()),
..HostConfig::default()
};
assert_eq!(
resolved_startup_command(&["echo".into(), "ok".into()], &config, &host),
Some(b"echo ok\n".to_vec())
);
assert_eq!(
resolved_startup_command(&[], &config, &host),
Some(b"tm\n".to_vec())
);
host.default_command = None;
assert_eq!(resolved_startup_command(&[], &config, &host), None);
}
#[test]
fn parses_local_forward_with_default_bind() {
assert_eq!(
+22
View File
@@ -136,6 +136,11 @@ pub struct ClientConfig {
pub send_env: Vec<String>,
#[serde(default)]
pub set_env: HashMap<String, String>,
/// Optional client-side command extensions. These are just shell command
/// templates expanded into the remote session's startup input; Dosh does not
/// depend on the tools they name.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -151,6 +156,22 @@ pub struct HostConfig {
pub send_env: Option<Vec<String>>,
#[serde(default)]
pub set_env: HashMap<String, String>,
/// Per-host extension overrides. A host can replace a global extension or set
/// `disabled = true` to opt out of it.
#[serde(default)]
pub extensions: HashMap<String, CommandExtension>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct CommandExtension {
/// Remote shell command template. `{args}` expands to shell-quoted extra
/// words after the extension name. When absent, extra args are appended.
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub disabled: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -186,6 +207,7 @@ impl Default for ClientConfig {
disconnect_status: true,
send_env: default_send_env(),
set_env: HashMap::new(),
extensions: HashMap::new(),
}
}
}