Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,36 @@ Alternatives:

After install, either restart your shell or run `export PATH="$HOME/.steel/bin:$PATH"`.

### Device login

`steel login` shows a short code and authorization URL; approve in a browser on
any machine. `steel login --resume` continues the saved request after interruption.
Non-interactive commands return promptly while approval is pending. Repeat
`steel init` after approval to continue setup. API keys remain supported, with
`STEEL_API_KEY` taking precedence for existing automation.

The defaults are production values. These settings may be independently overridden
for a test environment; an audience is an exact resource identifier, not an API URL:

| Setting | Default |
| --- | --- |
| `STEEL_AUTH_ISSUER` | `https://auth.steel.dev` |
| `STEEL_AUTH_AUDIENCE` | `https://api.steel.dev` |
| `STEEL_API_URL` | `https://api.steel.dev/v1` |

Use a separate `STEEL_CONFIG_DIR` for testing. Auth context, pending authorization
and credentials are saved privately in its `auth.json`; credentials are never
silently retargeted when an override changes. Device access tokens use Bearer
authorization and refresh automatically; API keys still use `Steel-Api-Key`.
CDP uses the connection URL returned by the API without appending an API key or
device token. A missing connection URL is an error, not a synthesized fallback.

`steel doctor --preflight` checks product API access. `steel logout` revokes the
saved refresh grant before deleting local state. `--local-only` explicitly forgets
local state when offline or recovering an interrupted refresh; revoke that device
in your account as well. Issued access JWTs can remain valid until expiry.
Do not paste or log access/refresh tokens, device codes or signed connection URLs.

## Shell Completions

Shell completions are installed automatically by `install.sh`. To regenerate or install manually:
Expand Down
36 changes: 28 additions & 8 deletions src/api/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ pub enum ApiError {
#[error("Missing Steel API key. Run `steel login` or set `STEEL_API_KEY`.")]
MissingAuth,

#[error("{0}")]
DeviceAuth(#[source] anyhow::Error),

#[error("Failed to reach Steel API at {url}.")]
Unreachable {
url: String,
Expand Down Expand Up @@ -83,6 +86,25 @@ pub struct SteelClient {
}

impl SteelClient {
pub(crate) async fn authorize(
&self,
request: reqwest::RequestBuilder,
base: &str,
auth: &Auth,
) -> Result<reqwest::RequestBuilder, ApiError> {
if auth.access_token.is_some() {
let token = crate::config::device::access_token_for(base)
.await
.map_err(ApiError::DeviceAuth)?;
return Ok(request.bearer_auth(token));
}
Ok(if let Some(key) = &auth.api_key {
request.header("Steel-Api-Key", key)
} else {
request
})
}

pub fn new() -> Result<Self, reqwest::Error> {
let http = Client::builder().user_agent("steel-cli").build()?;
Ok(Self { http })
Expand All @@ -100,7 +122,7 @@ impl SteelClient {
auth: &Auth,
) -> Result<Value, ApiError> {
// Cloud mode requires auth
if mode == ApiMode::Cloud && auth.api_key.is_none() {
if mode == ApiMode::Cloud && !auth.has_credentials() {
return Err(ApiError::MissingAuth);
}

Expand All @@ -109,9 +131,7 @@ impl SteelClient {
let mut req = self.http.request(method, &url);
req = req.header("Content-Type", "application/json");

if let Some(key) = &auth.api_key {
req = req.header("Steel-Api-Key", key);
}
req = self.authorize(req, base_url, auth).await?;

if let Some(body) = body {
req = req.json(&body);
Expand Down Expand Up @@ -156,16 +176,14 @@ impl SteelClient {
body: Option<Value>,
auth: &Auth,
) -> Result<reqwest::Response, ApiError> {
if mode == ApiMode::Cloud && auth.api_key.is_none() {
if mode == ApiMode::Cloud && !auth.has_credentials() {
return Err(ApiError::MissingAuth);
}

let url = format!("{base_url}{path}");
let mut req = self.http.request(method, &url);
req = req.header("Content-Type", "application/json");
if let Some(key) = &auth.api_key {
req = req.header("Steel-Api-Key", key);
}
req = self.authorize(req, base_url, auth).await?;
if let Some(body) = body {
req = req.json(&body);
}
Expand Down Expand Up @@ -273,6 +291,7 @@ mod tests {
let auth = Auth {
api_key: None,
source: crate::config::auth::AuthSource::None,
access_token: None,
};

let result = client
Expand All @@ -296,6 +315,7 @@ mod tests {
let auth = Auth {
api_key: None,
source: crate::config::auth::AuthSource::None,
access_token: None,
};

let result = client
Expand Down
1 change: 1 addition & 0 deletions src/api/projects.rs
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ mod tests {
let auth = Auth {
api_key: Some("test-key".into()),
source: AuthSource::Env,
access_token: None,
};

let data = client
Expand Down
1 change: 1 addition & 0 deletions src/api/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ mod tests {
Auth {
api_key: Some("test-key".into()),
source: AuthSource::Env,
access_token: None,
}
}

Expand Down
1 change: 1 addition & 0 deletions src/api/top_level.rs
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,7 @@ mod tests {
Auth {
api_key: Some("test-key".into()),
source: AuthSource::Env,
access_token: None,
}
}

Expand Down
2 changes: 2 additions & 0 deletions src/browser/daemon/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ pub const DEFAULT_INACTIVITY_TIMEOUT_MS: u64 = 120_000;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DaemonCreateParams {
pub api_key: Option<String>,
#[serde(default)]
pub device_auth: bool,
pub base_url: String,
pub mode: ApiMode,
pub session_name: String,
Expand Down
12 changes: 10 additions & 2 deletions src/browser/daemon/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()>
let api_client = SteelClient::new()?;
let auth = Auth {
api_key: params.api_key.clone(),
source: AuthSource::Env,
access_token: params.device_auth.then(String::new),
source: if params.device_auth {
AuthSource::Device
} else {
AuthSource::Env
},
};
let options = params.to_create_options();

Expand All @@ -47,7 +52,7 @@ pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()>
}
};

let summary = match to_session_summary(&session, params.mode, Some(&session_name), &auth) {
let summary = match to_session_summary(&session, params.mode, Some(&session_name)) {
Ok(s) => s,
Err(e) => {
cleanup(&session_name);
Expand All @@ -58,6 +63,9 @@ pub async fn run(session_name: String, params: DaemonCreateParams) -> Result<()>
let cdp_url = match summary.connect_url {
Some(ref url) => url.clone(),
None => {
let _ = api_client
.release_session(&params.base_url, params.mode, &summary.id, &auth)
.await;
cleanup(&session_name);
return Err(anyhow::anyhow!("Session has no CDP connect URL"));
}
Expand Down
5 changes: 4 additions & 1 deletion src/browser/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ pub struct BrowserEngine {
impl BrowserEngine {
/// Connect to an existing browser via CDP endpoint.
pub async fn connect(cdp_url: &str) -> Result<Self> {
let manager = BrowserManager::connect_cdp(cdp_url)
// Ensure root URLs with queries produce GET /?... rather than GET ?...
// in the native HTTP URI parser, matching browser URL serialization.
let normalized = url::Url::parse(cdp_url)?.to_string();
let manager = BrowserManager::connect_cdp(&normalized)
.await
.map_err(|e| anyhow::anyhow!(e))?;
Ok(Self {
Expand Down
39 changes: 11 additions & 28 deletions src/browser/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -203,33 +203,11 @@ pub fn to_session_summary(
session: &Value,
mode: ApiMode,
name: Option<&str>,
auth: &Auth,
) -> anyhow::Result<SessionSummary> {
let session_id = get_session_id(session)
.ok_or_else(|| anyhow::anyhow!("Session response did not contain an id."))?;

let mut connect_url = get_connect_url(session);

// Fallback: build connect URL for cloud mode
if connect_url.is_none()
&& mode == ApiMode::Cloud
&& let Some(ref api_key) = auth.api_key
{
connect_url = Some(format!(
"wss://connect.steel.dev?apiKey={api_key}&sessionId={session_id}"
));
}

// Inject apiKey into connect URL for cloud mode
if let Some(ref url) = connect_url
&& mode == ApiMode::Cloud
&& let Some(ref api_key) = auth.api_key
&& !url.contains("apiKey=")
&& let Ok(mut parsed) = url::Url::parse(url)
{
parsed.query_pairs_mut().append_pair("apiKey", api_key);
connect_url = Some(parsed.to_string());
}
let connect_url = get_connect_url(session);

Ok(SessionSummary {
id: session_id.clone(),
Expand Down Expand Up @@ -404,14 +382,19 @@ fn is_terminal_captcha_status(status: &str) -> bool {
matches!(status, "solved" | "failed" | "none")
}

/// Sanitize connect URL for display (mask API key).
/// Connection URLs are display metadata, not a credential export.
pub fn sanitize_connect_url(url: &str) -> String {
if let Ok(mut parsed) = url::Url::parse(url) {
let _ = parsed.set_username("");
let _ = parsed.set_password(None);
let pairs: Vec<(String, String)> = parsed
.query_pairs()
.map(|(k, v)| {
if k == "apiKey" && v.len() > 7 {
(k.to_string(), format!("{}...", &v[..7]))
if matches!(
k.to_ascii_lowercase().as_str(),
"apikey" | "api_key" | "token" | "access_token"
) {
(k.to_string(), "[redacted]".to_string())
} else {
(k.to_string(), v.to_string())
}
Expand All @@ -427,7 +410,7 @@ pub fn sanitize_connect_url(url: &str) -> String {

parsed.to_string()
} else {
url.to_string()
"[redacted connection URL]".to_string()
}
}

Expand Down Expand Up @@ -509,7 +492,7 @@ mod tests {
fn sanitize_masks_api_key() {
let url = "wss://connect.steel.dev?apiKey=sk-12345678&sessionId=s1";
let sanitized = sanitize_connect_url(url);
assert!(sanitized.contains("sk-1234..."));
assert!(!sanitized.contains("sk-1234"));
assert!(sanitized.contains("sessionId=s1"));
}

Expand Down
20 changes: 11 additions & 9 deletions src/browser/profile_porter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -966,7 +966,7 @@ pub fn package_profile(

pub async fn upload_profile_to_steel(
zip_buffer: Vec<u8>,
api_key: &str,
auth: &crate::config::auth::Auth,
api_base: &str,
) -> Result<String> {
let part = reqwest::multipart::Part::bytes(zip_buffer)
Expand All @@ -975,10 +975,10 @@ pub async fn upload_profile_to_steel(
let form = reqwest::multipart::Form::new().part("userDataDir", part);

let client = reqwest::Client::new();
let res = client
.post(format!("{api_base}/profiles"))
.header("Steel-Api-Key", api_key)
.multipart(form)
let request = client.post(format!("{api_base}/profiles")).multipart(form);
let res = crate::api::client::SteelClient::new()?
.authorize(request, api_base, auth)
.await?
.send()
.await?;

Expand All @@ -1003,7 +1003,7 @@ pub async fn upload_profile_to_steel(
pub async fn update_profile_on_steel(
profile_id: &str,
zip_buffer: Vec<u8>,
api_key: &str,
auth: &crate::config::auth::Auth,
api_base: &str,
) -> Result<()> {
let part = reqwest::multipart::Part::bytes(zip_buffer)
Expand All @@ -1012,10 +1012,12 @@ pub async fn update_profile_on_steel(
let form = reqwest::multipart::Form::new().part("userDataDir", part);

let client = reqwest::Client::new();
let res = client
let request = client
.patch(format!("{api_base}/profiles/{profile_id}"))
.header("Steel-Api-Key", api_key)
.multipart(form)
.multipart(form);
let res = crate::api::client::SteelClient::new()?
.authorize(request, api_base, auth)
.await?
.send()
.await?;

Expand Down
1 change: 1 addition & 0 deletions src/commands/browser/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1250,6 +1250,7 @@ async fn ensure_daemon(session_name: Option<&str>) -> Result<DaemonClient> {
let (mode, base_url, auth) = api::resolve_with_auth();
let params = DaemonCreateParams {
api_key: auth.api_key,
device_auth: auth.access_token.is_some(),
base_url,
mode,
session_name: name.to_string(),
Expand Down
1 change: 1 addition & 0 deletions src/commands/browser/start.rs
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ pub async fn run(args: Args, session: Option<&str>) -> anyhow::Result<()> {
// Build params and spawn daemon
let params = DaemonCreateParams {
api_key: auth.api_key,
device_auth: auth.access_token.is_some(),
base_url,
mode,
session_name: session_name.clone(),
Expand Down
11 changes: 10 additions & 1 deletion src/commands/computer/ssh.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ impl Handler for Client {
pub async fn run(args: Args) -> Result<()> {
let id = resolve_computer_id(args.computer_id.as_deref())?;
let (mode, base_url, auth) = api::resolve_with_auth();
if mode == ApiMode::Cloud && auth.api_key.is_none() {
if mode == ApiMode::Cloud && !auth.has_credentials() {
return Err(ApiError::MissingAuth.into());
}
let url = ssh_url(&base_url, &id)?;
Expand Down Expand Up @@ -123,6 +123,15 @@ async fn connect(url: &str, auth: &Auth) -> Result<WsIo<MaybeTlsStream<TcpStream
HeaderValue::from_str(key.trim()).context("The API key is not a valid header value")?,
);
}
if auth.access_token.is_some() {
let (_, base) = api::resolve();
crate::config::device::validate_websocket_transport(&base, url)?;
let token = crate::config::device::access_token_for(&base).await?;
request.headers_mut().insert(
"Authorization",
HeaderValue::from_str(&format!("Bearer {token}")).context("Invalid Bearer header")?,
);
}
let (stream, response) = connect_async(request)
.await
.context("Failed to open the SSH bridge to the computer")?;
Expand Down
7 changes: 5 additions & 2 deletions src/commands/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ pub async fn run(_args: Args) -> anyhow::Result<()> {
let auth = auth::resolve_auth();
let config = read_config().ok();

if auth.api_key.is_none() && config.is_none() {
if !auth.has_credentials() && config.is_none() {
println!("You are not logged in. Please run `steel login` to authenticate.");
return Ok(());
}
Expand All @@ -30,6 +30,9 @@ pub async fn run(_args: Args) -> anyhow::Result<()> {
}

print_project_info(&auth).await;
if auth.access_token.is_some() {
println!("source: device (Bearer; token redacted)");
}

if let Some(ref cfg) = config
&& let Some(ref browser) = cfg.browser
Expand All @@ -50,7 +53,7 @@ pub async fn run(_args: Args) -> anyhow::Result<()> {
/// so `steel config` stays usable offline.
async fn print_project_info(auth: &Auth) {
let (mode, base_url) = crate::util::api::resolve();
if mode != ApiMode::Cloud || auth.api_key.is_none() {
if mode != ApiMode::Cloud || !auth.has_credentials() {
return;
}
let Ok(client) = SteelClient::new() else {
Expand Down
Loading
Loading