From 1635566db982c6cef394898b573e62294ea31f56 Mon Sep 17 00:00:00 2001 From: "hh.(SII)" Date: Tue, 22 Sep 2026 10:29:13 +0800 Subject: [PATCH 1/3] Add account API login and online device listing to lab CLI --- Cargo.lock | 23 +++ README.md | 20 +++ crates/lab-cli/Cargo.toml | 3 + crates/lab-cli/src/commands/api.rs | 259 +++++++++++++++++++++++++++++ crates/lab-cli/src/commands/mod.rs | 1 + crates/lab-cli/src/main.rs | 6 + 6 files changed, 312 insertions(+) create mode 100644 crates/lab-cli/src/commands/api.rs diff --git a/Cargo.lock b/Cargo.lock index 82774a9..6b2d361 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1656,6 +1656,8 @@ dependencies = [ "osdl-server", "prost", "prost-types", + "reqwest 0.12.28", + "rpassword", "serde", "serde_json", "serde_yaml", @@ -2773,6 +2775,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -2783,6 +2796,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "rtoolbox" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a1efe12a1469752d0e6ff5ebec0b6ef4924cc5c4c71046b0ec730040535819d" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "rumqttc" version = "0.24.0" diff --git a/README.md b/README.md index c6a3089..e8a8801 100644 --- a/README.md +++ b/README.md @@ -204,6 +204,26 @@ firmware/ └── esp32-cpp/ # MQTT bridge (C++ / PlatformIO) ``` +## View devices connected to your SciLaxy account + +Create an OpenSDL API token in SciLaxy **Settings → Clients**. The token is +shown once. Save it with the CLI, then query your online runners and devices: + +```bash +lab api login --server https://scilaxy.ai +lab api devices +lab api devices --json +lab api logout +``` + +`lab api login` prompts for the token without showing it on the terminal. +For scripts, pipe it to `lab api login --server URL --token-stdin`. The token +grants only the read-only OpenSDL account API; revoke it in Settings when it +is no longer needed. Devices appear after the selected Desktop or Runner is +online, its Lab Server is running, and the corresponding hardware transport +and device registry are configured. A USB plug-in alone does not configure +a serial port or identify a device driver. + ## Build from source Install a current stable [Rust toolchain](https://rustup.rs/) and your platform's diff --git a/crates/lab-cli/Cargo.toml b/crates/lab-cli/Cargo.toml index 256f2ac..4327cab 100644 --- a/crates/lab-cli/Cargo.toml +++ b/crates/lab-cli/Cargo.toml @@ -62,6 +62,9 @@ prost-types.workspace = true hyper-util.workspace = true tower.workspace = true anyhow = "1" +reqwest = { workspace = true, features = ["json"] } +rpassword = "7" +tempfile.workspace = true [target.'cfg(unix)'.dependencies] daemonize = { workspace = true } diff --git a/crates/lab-cli/src/commands/api.rs b/crates/lab-cli/src/commands/api.rs new file mode 100644 index 0000000..cde826f --- /dev/null +++ b/crates/lab-cli/src/commands/api.rs @@ -0,0 +1,259 @@ +//! Account API commands. Credentials are scoped to OpenSDL read access and +//! kept separate from local gRPC and OCI registry authentication. + +use std::fs; +use std::io::{self, Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{bail, Context}; +use clap::{Args, Subcommand}; +use osdl_server::paths::Paths; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Subcommand)] +pub enum ApiCmd { + /// Save an account API token after checking it with the server. + Login(LoginArgs), + /// Remove the saved account API token from this machine. + Logout, + /// Show online OpenSDL devices on your connected runners. + Devices(DevicesArgs), +} + +#[derive(Debug, Args)] +pub struct LoginArgs { + /// Server origin, e.g. https://scilaxy.ai (without an API path). + #[arg(long)] + server: String, + /// Read the token from stdin instead of a hidden terminal prompt. + #[arg(long)] + token_stdin: bool, +} + +#[derive(Debug, Args)] +pub struct DevicesArgs { + /// Emit the response as JSON. + #[arg(long)] + json: bool, +} + +#[derive(Serialize, Deserialize)] +struct Credentials { + server: String, + token: String, +} + +#[derive(Debug, Deserialize)] +struct Runner { + runner_id: String, + name: String, + online: bool, + devices: Vec, + error: Option, +} + +#[derive(Debug, Deserialize)] +struct Device { + device_id: String, + device_type: String, + role: Option, + online: bool, +} + +#[derive(Debug, Deserialize)] +struct DeviceResponse { + runners: Vec, +} + +pub async fn run(cmd: ApiCmd) -> anyhow::Result<()> { + let path = credentials_path()?; + match cmd { + ApiCmd::Login(args) => login(args, &path).await, + ApiCmd::Logout => { + if path.exists() { + fs::remove_file(&path).with_context(|| format!("remove {}", path.display()))?; + } + println!("Account API token removed from this machine."); + Ok(()) + } + ApiCmd::Devices(args) => devices(args, &path).await, + } +} + +async fn login(args: LoginArgs, path: &Path) -> anyhow::Result<()> { + let server = parse_server(&args.server)?; + let token = if args.token_stdin { + let mut value = String::new(); + io::stdin().take(256).read_to_string(&mut value)?; + value.trim().to_owned() + } else { + rpassword::prompt_password("OpenSDL API token: ")? + }; + if !token.starts_with("sdl_") || token.len() != 47 { + bail!("invalid OpenSDL API token format"); + } + let client = http_client()?; + let response = client + .get(endpoint(&server, "me")?) + .bearer_auth(&token) + .send() + .await + .context("verify account API token")?; + if !response.status().is_success() { + bail!("API login failed: HTTP {}", response.status()); + } + save_credentials(path, &Credentials { server, token })?; + println!("Authenticated to SciLaxy. Run `lab api devices` to view online devices."); + Ok(()) +} + +async fn devices(args: DevicesArgs, path: &Path) -> anyhow::Result<()> { + let credentials = read_credentials(path)?; + let response = http_client()? + .get(endpoint(&credentials.server, "devices")?) + .bearer_auth(&credentials.token) + .send() + .await + .context("query account devices")?; + if !response.status().is_success() { + bail!("device query failed: HTTP {}", response.status()); + } + let body = response.bytes().await.context("read device list")?; + if args.json { + let value: serde_json::Value = + serde_json::from_slice(&body).context("decode device list")?; + println!("{}", serde_json::to_string_pretty(&value)?); + return Ok(()); + } + let response: DeviceResponse = serde_json::from_slice(&body).context("decode device list")?; + for runner in response.runners { + println!( + "{} ({}) — {}", + runner.name, + runner.runner_id, + if runner.online { "online" } else { "offline" } + ); + if let Some(error) = runner.error { + println!(" {error}"); + } + for device in runner.devices { + if device.online { + println!( + " {} {} {}", + device.device_id, + device.device_type, + device.role.as_deref().unwrap_or("-") + ); + } + } + } + Ok(()) +} + +fn http_client() -> anyhow::Result { + Ok(reqwest::Client::builder() + .timeout(Duration::from_secs(20)) + .build()?) +} + +fn parse_server(raw: &str) -> anyhow::Result { + let url = reqwest::Url::parse(raw).context("invalid server URL")?; + let local = matches!( + url.host_str(), + Some("localhost" | "127.0.0.1" | "::1" | "scilaxy.local") + ); + if url.scheme() != "https" && !(url.scheme() == "http" && local) { + bail!("server must use HTTPS (HTTP is allowed only for local development)"); + } + if url.username() != "" + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + || url.path() != "/" + { + bail!("server must be an origin without credentials, path, query, or fragment"); + } + Ok(url.origin().ascii_serialization()) +} + +fn endpoint(server: &str, resource: &str) -> anyhow::Result { + Ok(reqwest::Url::parse(server)?.join(&format!("/liyanlabs/api/v1/osdl/{resource}"))?) +} + +fn credentials_path() -> anyhow::Result { + let paths = Paths::discover().map_err(anyhow::Error::msg)?; + Ok(paths.config_dir.join("account-api.json")) +} + +fn read_credentials(path: &Path) -> anyhow::Result { + let bytes = fs::read(path).with_context(|| { + format!( + "no saved account token; run `lab api login --server URL` ({})", + path.display() + ) + })?; + serde_json::from_slice(&bytes).context("invalid saved account API credentials") +} + +fn save_credentials(path: &Path, credentials: &Credentials) -> anyhow::Result<()> { + let parent = path + .parent() + .context("account API config has no parent directory")?; + let mut file = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("create credential file in {}", parent.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + file.as_file() + .set_permissions(fs::Permissions::from_mode(0o600))?; + } + file.write_all(&serde_json::to_vec(credentials)?)?; + file.as_file().sync_all()?; + file.persist(path) + .with_context(|| format!("save {}", path.display()))?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn server_must_be_a_secure_origin() { + assert_eq!( + parse_server("https://example.org/").unwrap(), + "https://example.org" + ); + for raw in [ + "http://example.org", + "https://example.org/api", + "https://user:pass@example.org", + "https://example.org?token=secret", + ] { + assert!(parse_server(raw).is_err(), "accepted {raw}"); + } + } + + #[test] + fn saved_credentials_are_private_and_round_trip() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("account-api.json"); + let credentials = Credentials { + server: "https://example.org".into(), + token: "sdl_test".into(), + }; + save_credentials(&path, &credentials).unwrap(); + let loaded = read_credentials(&path).unwrap(); + assert_eq!(loaded.server, credentials.server); + assert_eq!(loaded.token, credentials.token); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } + } +} diff --git a/crates/lab-cli/src/commands/mod.rs b/crates/lab-cli/src/commands/mod.rs index b53b1cf..699c922 100644 --- a/crates/lab-cli/src/commands/mod.rs +++ b/crates/lab-cli/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod api; pub mod device; pub mod events; pub mod inspect; diff --git a/crates/lab-cli/src/main.rs b/crates/lab-cli/src/main.rs index 4598a82..4720dc9 100644 --- a/crates/lab-cli/src/main.rs +++ b/crates/lab-cli/src/main.rs @@ -34,6 +34,11 @@ struct Cli { #[derive(Subcommand)] enum Command { + /// Authenticate to SciLaxy and inspect online lab devices. + Api { + #[command(subcommand)] + cmd: commands::api::ApiCmd, + }, /// Boot the engine + gRPC server in this process. Serve(commands::serve::ServeArgs), /// Show server identity and engine status. @@ -103,6 +108,7 @@ fn main() { Command::Stop => commands::stop::run(opts).await, Command::Push(args) => commands::push::run(args).await, Command::Pull(args) => commands::pull::run(args).await, + Command::Api { cmd } => commands::api::run(cmd).await, Command::Validate(_) | Command::Pack(_) | Command::Inspect(_) => unreachable!(), } }) From 4ebf10902a92dd8b5667cae1bb290eb658034559 Mon Sep 17 00:00:00 2001 From: "hh.(SII)" Date: Tue, 22 Sep 2026 10:53:22 +0800 Subject: [PATCH 2/3] Verify lab account API against local HTTP server --- crates/lab-cli/Cargo.toml | 3 +- crates/lab-cli/src/commands/api.rs | 44 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/crates/lab-cli/Cargo.toml b/crates/lab-cli/Cargo.toml index 4327cab..e0a4b5b 100644 --- a/crates/lab-cli/Cargo.toml +++ b/crates/lab-cli/Cargo.toml @@ -36,7 +36,6 @@ required-features = ["osdl-core/espnow"] [dev-dependencies] serde_json.workspace = true -tempfile.workspace = true [features] default = ["espnow"] @@ -62,7 +61,7 @@ prost-types.workspace = true hyper-util.workspace = true tower.workspace = true anyhow = "1" -reqwest = { workspace = true, features = ["json"] } +reqwest.workspace = true rpassword = "7" tempfile.workspace = true diff --git a/crates/lab-cli/src/commands/api.rs b/crates/lab-cli/src/commands/api.rs index cde826f..8830b37 100644 --- a/crates/lab-cli/src/commands/api.rs +++ b/crates/lab-cli/src/commands/api.rs @@ -90,6 +90,10 @@ async fn login(args: LoginArgs, path: &Path) -> anyhow::Result<()> { } else { rpassword::prompt_password("OpenSDL API token: ")? }; + verify_and_save(server, token, path).await +} + +async fn verify_and_save(server: String, token: String, path: &Path) -> anyhow::Result<()> { if !token.starts_with("sdl_") || token.len() != 47 { bail!("invalid OpenSDL API token format"); } @@ -218,6 +222,7 @@ fn save_credentials(path: &Path, credentials: &Credentials) -> anyhow::Result<() #[cfg(test)] mod tests { use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[test] fn server_must_be_a_secure_origin() { @@ -256,4 +261,43 @@ mod tests { ); } } + + #[tokio::test] + async fn login_and_devices_use_the_scoped_account_api() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let server = format!("http://{}", listener.local_addr().unwrap()); + let token = format!("sdl_{}", "a".repeat(43)); + let expected_token = token.clone(); + let responder = tokio::spawn(async move { + for (path, body) in [ + ("/liyanlabs/api/v1/osdl/me", r#"{"user_id":"owner"}"#), + ("/liyanlabs/api/v1/osdl/devices", r#"{"runners":[]}"#), + ] { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut bytes = [0u8; 2048]; + let mut request = Vec::new(); + while !request.windows(4).any(|part| part == b"\r\n\r\n") { + let size = socket.read(&mut bytes).await.unwrap(); + assert!(size > 0); + request.extend_from_slice(&bytes[..size]); + } + let request = String::from_utf8_lossy(&request); + assert!(request.starts_with(&format!("GET {path} HTTP/1.1"))); + assert!(request.contains(&format!("Bearer {expected_token}"))); + let reply = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket.write_all(reply.as_bytes()).await.unwrap(); + } + }); + + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("account-api.json"); + verify_and_save(parse_server(&server).unwrap(), token, &path) + .await + .unwrap(); + devices(DevicesArgs { json: false }, &path).await.unwrap(); + responder.await.unwrap(); + } } From 356e48c763fb57ad0a087d360f1709b6200a847f Mon Sep 17 00:00:00 2001 From: "hh.(SII)" Date: Tue, 22 Sep 2026 11:02:58 +0800 Subject: [PATCH 3/3] ci: retry transient registry readiness errors --- .github/workflows/checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index be18718..3a02e89 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -49,7 +49,7 @@ jobs: if: runner.os == 'Linux' run: | docker run --detach --publish 127.0.0.1:5000:5000 registry:3 - curl --fail --silent --show-error --retry 30 --retry-delay 1 --retry-connrefused --retry-max-time 30 --max-time 2 http://127.0.0.1:5000/v2/ + curl --fail --silent --show-error --retry 30 --retry-delay 1 --retry-all-errors --retry-max-time 30 --max-time 2 http://127.0.0.1:5000/v2/ - name: Test workspace including OCI integration if: runner.os == 'Linux' env: