feat: find-window, run, clipboard, label-routing, persistent session-id, exe icon

This commit is contained in:
Helios Agent 2026-03-03 15:19:54 +01:00
parent ef4ca0ccbb
commit 672676d3d7
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
9 changed files with 214 additions and 15 deletions

View file

@ -37,8 +37,8 @@ jobs:
with: with:
targets: x86_64-pc-windows-gnu targets: x86_64-pc-windows-gnu
- name: Install MinGW cross-compiler - name: Install MinGW cross-compiler and tools
run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 mingw-w64-tools
- name: Cache dependencies - name: Cache dependencies
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2

View file

@ -70,6 +70,16 @@ All endpoints require the `X-Api-Key` header.
| `POST` | `/sessions/:id/click` | Simulate a mouse click | | `POST` | `/sessions/:id/click` | Simulate a mouse click |
| `POST` | `/sessions/:id/type` | Type text | | `POST` | `/sessions/:id/type` | Type text |
| `POST` | `/sessions/:id/label` | Rename a session | | `POST` | `/sessions/:id/label` | Rename a session |
| `GET` | `/sessions/:id/windows` | List all windows |
| `POST` | `/sessions/:id/windows/minimize-all` | Minimize all windows |
| `POST` | `/sessions/:id/windows/:window_id/focus` | Focus a window |
| `POST` | `/sessions/:id/windows/:window_id/maximize` | Maximize and focus a window |
| `POST` | `/sessions/:id/run` | Launch a program (fire-and-forget) |
| `GET` | `/sessions/:id/clipboard` | Get clipboard contents |
| `POST` | `/sessions/:id/clipboard` | Set clipboard contents |
| `GET` | `/sessions/:id/version` | Get client version |
| `POST` | `/sessions/:id/upload` | Upload a file to the client |
| `GET` | `/sessions/:id/download?path=...` | Download a file from the client |
### WebSocket ### WebSocket
@ -112,6 +122,41 @@ curl -s -X POST -H "X-Api-Key: your-secret-key" \
http://localhost:3000/sessions/<session-id>/click http://localhost:3000/sessions/<session-id>/click
``` ```
## remote.py CLI
The `skills/helios-remote/remote.py` script provides a simple CLI wrapper around the REST API.
### Label Routing
All commands accept either a UUID or a label name as `session_id`. If the value is not a UUID, the script resolves it by looking up the label across all connected sessions:
```bash
python remote.py screenshot "Moritz PC" # resolves label → UUID automatically
python remote.py exec "Moritz PC" whoami
```
### Commands
```bash
python remote.py sessions # list sessions
python remote.py screenshot <session> # capture screenshot → /tmp/helios-remote-screenshot.png
python remote.py exec <session> <command...> # run shell command
python remote.py click <session> <x> <y> # mouse click
python remote.py type <session> <text> # keyboard input
python remote.py windows <session> # list windows
python remote.py find-window <session> <title> # filter windows by title substring
python remote.py minimize-all <session> # minimize all windows
python remote.py focus <session> <window_id> # focus window
python remote.py maximize <session> <window_id> # maximize and focus window
python remote.py run <session> <program> [args...] # launch program (fire-and-forget)
python remote.py clipboard-get <session> # get clipboard text
python remote.py clipboard-set <session> <text> # set clipboard text
python remote.py upload <session> <local> <remote> # upload file
python remote.py download <session> <remote> <local> # download file
python remote.py version <session> # client version
python remote.py server-version # server version
```
## Client (Phase 2) ## Client (Phase 2)
See [`crates/client/README.md`](crates/client/README.md) for the planned Windows client implementation. See [`crates/client/README.md`](crates/client/README.md) for the planned Windows client implementation.

View file

@ -16,6 +16,7 @@ serde_json = "1"
toml = "0.8" toml = "0.8"
chrono = "0.4" chrono = "0.4"
helios-common = { path = "../common" } helios-common = { path = "../common" }
uuid = { version = "1", features = ["v4"] }
dirs = "5" dirs = "5"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View file

@ -8,4 +8,15 @@ fn main() {
let hash = hash.trim(); let hash = hash.trim();
println!("cargo:rustc-env=GIT_COMMIT={}", if hash.is_empty() { "unknown" } else { hash }); println!("cargo:rustc-env=GIT_COMMIT={}", if hash.is_empty() { "unknown" } else { hash });
println!("cargo:rerun-if-changed=.git/HEAD"); println!("cargo:rerun-if-changed=.git/HEAD");
// Embed Windows icon when cross-compiling for Windows
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut res = winres::WindowsResource::new();
res.set_icon("../../assets/logo.ico");
// Set cross-compile toolkit (mingw-w64)
res.set_toolkit_path("/usr");
res.set_windres_path("x86_64-w64-mingw32-windres");
res.set_ar_path("x86_64-w64-mingw32-ar");
res.compile().unwrap_or_else(|e| eprintln!("winres warning: {e}"));
}
} }

View file

@ -11,6 +11,7 @@ use tokio_tungstenite::{connect_async_tls_with_config, tungstenite::Message, Con
use base64::Engine; use base64::Engine;
use helios_common::{ClientMessage, ServerMessage}; use helios_common::{ClientMessage, ServerMessage};
use uuid::Uuid;
mod shell; mod shell;
mod screenshot; mod screenshot;
@ -49,22 +50,14 @@ macro_rules! log_cmd {
}; };
} }
fn session_id() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.subsec_nanos();
format!("{:06x}", t & 0xFFFFFF)
}
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
struct Config { struct Config {
relay_url: String, relay_url: String,
api_key: String, api_key: String,
label: Option<String>, label: Option<String>,
session_id: Option<String>, // persistent UUID
} }
impl Config { impl Config {
@ -129,7 +122,7 @@ fn prompt_config() -> Config {
} }
}; };
Config { relay_url, api_key, label } Config { relay_url, api_key, label, session_id: None }
} }
#[tokio::main] #[tokio::main]
@ -158,6 +151,20 @@ async fn main() {
} }
}; };
// Resolve or generate persistent session UUID
let sid: Uuid = match &config.session_id {
Some(id) => Uuid::parse_str(id).unwrap_or_else(|_| Uuid::new_v4()),
None => {
let id = Uuid::new_v4();
let mut cfg = config.clone();
cfg.session_id = Some(id.to_string());
if let Err(e) = cfg.save() {
log_err!("Failed to save session_id: {e}");
}
id
}
};
let config = Arc::new(config); let config = Arc::new(config);
let shell = Arc::new(Mutex::new(shell::PersistentShell::new())); let shell = Arc::new(Mutex::new(shell::PersistentShell::new()));
@ -183,14 +190,13 @@ async fn main() {
match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await { match connect_async_tls_with_config(&config.relay_url, None, false, Some(connector)).await {
Ok((ws_stream, _)) => { Ok((ws_stream, _)) => {
let sid = session_id();
let label = config.label.clone().unwrap_or_else(|| hostname()); let label = config.label.clone().unwrap_or_else(|| hostname());
log_ok!( log_ok!(
"Connected {} {} {} Session {}", "Connected {} {} {} Session {}",
"·".dimmed(), "·".dimmed(),
label.bold(), label.bold(),
"·".dimmed(), "·".dimmed(),
sid.dimmed() sid.to_string().dimmed()
); );
println!(); println!();
backoff = Duration::from_secs(1); backoff = Duration::from_secs(1);
@ -499,6 +505,51 @@ async fn handle_message(
} }
} }
ServerMessage::RunRequest { request_id, program, args } => {
log_cmd!("run {}", program);
use std::process::Command as StdCommand;
match StdCommand::new(&program).args(&args).spawn() {
Ok(_) => {
log_ok!("Started {}", program);
ClientMessage::Ack { request_id }
}
Err(e) => {
log_err!("run failed: {e}");
ClientMessage::Error { request_id, message: format!("Failed to start '{}': {e}", program) }
}
}
}
ServerMessage::ClipboardGetRequest { request_id } => {
log_cmd!("clipboard-get");
let out = tokio::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", "Get-Clipboard"])
.output().await;
match out {
Ok(o) => {
let text = String::from_utf8_lossy(&o.stdout).trim().to_string();
log_ok!("Got {} chars", text.len());
ClientMessage::ClipboardGetResponse { request_id, text }
}
Err(e) => ClientMessage::Error { request_id, message: format!("Clipboard get failed: {e}") }
}
}
ServerMessage::ClipboardSetRequest { request_id, text } => {
log_cmd!("clipboard-set {} chars", text.len());
let cmd = format!("Set-Clipboard -Value '{}'", text.replace('\'', "''"));
let out = tokio::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", &cmd])
.output().await;
match out {
Ok(_) => {
log_ok!("Set clipboard");
ClientMessage::Ack { request_id }
}
Err(e) => ClientMessage::Error { request_id, message: format!("Clipboard set failed: {e}") }
}
}
ServerMessage::Ack { request_id } => { ServerMessage::Ack { request_id } => {
ClientMessage::Ack { request_id } ClientMessage::Ack { request_id }
} }

View file

@ -60,6 +60,16 @@ pub enum ServerMessage {
request_id: Uuid, request_id: Uuid,
path: String, path: String,
}, },
/// Launch a program on the client (fire-and-forget)
RunRequest {
request_id: Uuid,
program: String,
args: Vec<String>,
},
/// Get the contents of the client's clipboard
ClipboardGetRequest { request_id: Uuid },
/// Set the contents of the client's clipboard
ClipboardSetRequest { request_id: Uuid, text: String },
} }
/// Messages sent from the client to the relay server /// Messages sent from the client to the relay server
@ -106,6 +116,8 @@ pub enum ClientMessage {
content_base64: String, content_base64: String,
size: u64, size: u64,
}, },
/// Response to a clipboard-get request
ClipboardGetResponse { request_id: Uuid, text: String },
} }
/// Mouse button variants /// Mouse button variants

View file

@ -404,6 +404,81 @@ pub async fn download_file(
} }
} }
/// POST /sessions/:id/run
#[derive(Deserialize)]
pub struct RunBody {
pub program: String,
#[serde(default)]
pub args: Vec<String>,
}
pub async fn run_program(
Path(session_id): Path<String>,
State(state): State<AppState>,
Json(body): Json<RunBody>,
) -> impl IntoResponse {
match dispatch(&state, &session_id, "run", |rid| ServerMessage::RunRequest {
request_id: rid,
program: body.program.clone(),
args: body.args.clone(),
})
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// GET /sessions/:id/clipboard
pub async fn clipboard_get(
Path(session_id): Path<String>,
State(state): State<AppState>,
) -> impl IntoResponse {
match dispatch(&state, &session_id, "clipboard_get", |rid| {
ServerMessage::ClipboardGetRequest { request_id: rid }
})
.await
{
Ok(ClientMessage::ClipboardGetResponse { text, .. }) => (
StatusCode::OK,
Json(serde_json::json!({ "text": text })),
)
.into_response(),
Ok(ClientMessage::Error { message, .. }) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": message })),
)
.into_response(),
Ok(_) => (
StatusCode::BAD_GATEWAY,
Json(serde_json::json!({ "error": "Unexpected response from client" })),
)
.into_response(),
Err(e) => e.into_response(),
}
}
/// POST /sessions/:id/clipboard
#[derive(Deserialize)]
pub struct ClipboardSetBody {
pub text: String,
}
pub async fn clipboard_set(
Path(session_id): Path<String>,
State(state): State<AppState>,
Json(body): Json<ClipboardSetBody>,
) -> impl IntoResponse {
match dispatch(&state, &session_id, "clipboard_set", |rid| {
ServerMessage::ClipboardSetRequest { request_id: rid, text: body.text.clone() }
})
.await
{
Ok(_) => (StatusCode::OK, Json(serde_json::json!({ "ok": true }))).into_response(),
Err(e) => e.into_response(),
}
}
/// POST /sessions/:id/label /// POST /sessions/:id/label
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LabelBody { pub struct LabelBody {

View file

@ -58,6 +58,9 @@ async fn main() -> anyhow::Result<()> {
.route("/sessions/:id/version", get(api::client_version)) .route("/sessions/:id/version", get(api::client_version))
.route("/sessions/:id/upload", post(api::upload_file)) .route("/sessions/:id/upload", post(api::upload_file))
.route("/sessions/:id/download", get(api::download_file)) .route("/sessions/:id/download", get(api::download_file))
.route("/sessions/:id/run", post(api::run_program))
.route("/sessions/:id/clipboard", get(api::clipboard_get))
.route("/sessions/:id/clipboard", post(api::clipboard_set))
.layer(middleware::from_fn_with_state(state.clone(), require_api_key)); .layer(middleware::from_fn_with_state(state.clone(), require_api_key));
let app = Router::new() let app = Router::new()

View file

@ -91,6 +91,7 @@ async fn handle_client_message(session_id: Uuid, msg: ClientMessage, state: &App
| ClientMessage::ListWindowsResponse { request_id, .. } | ClientMessage::ListWindowsResponse { request_id, .. }
| ClientMessage::VersionResponse { request_id, .. } | ClientMessage::VersionResponse { request_id, .. }
| ClientMessage::DownloadResponse { request_id, .. } | ClientMessage::DownloadResponse { request_id, .. }
| ClientMessage::ClipboardGetResponse { request_id, .. }
| ClientMessage::Ack { request_id } | ClientMessage::Ack { request_id }
| ClientMessage::Error { request_id, .. } => { | ClientMessage::Error { request_id, .. } => {
let rid = *request_id; let rid = *request_id;