From 32fdd076bf27b8b740ef47981aaeb7ad1130315a Mon Sep 17 00:00:00 2001 From: Hare Date: Sat, 29 Aug 2026 23:41:46 +0900 Subject: [PATCH 1/2] feat: authenticate Backend target requests --- Cargo.lock | 1 + crates/client/Cargo.toml | 1 + crates/client/src/backend_api.rs | 768 +++++++++++++++++++++++++ crates/client/src/backend_auth.rs | 9 +- crates/client/src/backend_runtime.rs | 187 ++++-- crates/client/src/backend_workspace.rs | 98 ++-- crates/client/src/lib.rs | 7 +- crates/client/src/target.rs | 15 +- crates/client/src/workspace_product.rs | 124 ++-- crates/yoi/src/main.rs | 63 +- 10 files changed, 1075 insertions(+), 198 deletions(-) create mode 100644 crates/client/src/backend_api.rs diff --git a/Cargo.lock b/Cargo.lock index e4f7f2bb..6d3627ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -637,6 +637,7 @@ checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" name = "client" version = "0.1.0" dependencies = [ + "chrono", "futures", "manifest", "protocol", diff --git a/crates/client/Cargo.toml b/crates/client/Cargo.toml index bb5f92a3..8cf7d566 100644 --- a/crates/client/Cargo.toml +++ b/crates/client/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true [dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock"] } protocol = { workspace = true } manifest = { workspace = true } ticket = { workspace = true } diff --git a/crates/client/src/backend_api.rs b/crates/client/src/backend_api.rs new file mode 100644 index 00000000..be4c7519 --- /dev/null +++ b/crates/client/src/backend_api.rs @@ -0,0 +1,768 @@ +use chrono::{DateTime, Utc}; +use reqwest::{Method, StatusCode, Url, redirect}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use std::env; +use std::fmt; +use std::fs::{self, OpenOptions}; +use std::io::Write as _; +use std::path::{Path, PathBuf}; + +const TOKEN_FILE_NAME: &str = "backend-tokens.json"; +const MAX_REDIRECTS: usize = 10; + +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct BackendOrigin(String); + +impl BackendOrigin { + pub fn parse(input: &str) -> Result { + let url = Url::parse(input.trim()).map_err(|error| { + BackendApiClientError::InvalidBackendOrigin(format!( + "Backend URL is not a valid absolute URL: {error}" + )) + })?; + if !url.path().bytes().all(|byte| byte == b'/') + || url.query().is_some() + || url.fragment().is_some() + { + return Err(BackendApiClientError::InvalidBackendOrigin( + "Backend URL must contain only an origin, without a path, query, or fragment" + .to_string(), + )); + } + Self::from_url(url) + } + + fn from_url(mut url: Url) -> Result { + if !matches!(url.scheme(), "http" | "https") { + return Err(BackendApiClientError::InvalidBackendOrigin( + "Backend URL scheme must be http or https".to_string(), + )); + } + if !url.username().is_empty() || url.password().is_some() { + return Err(BackendApiClientError::InvalidBackendOrigin( + "Backend URL must not contain user information".to_string(), + )); + } + if url.host().is_none() { + return Err(BackendApiClientError::InvalidBackendOrigin( + "Backend URL must contain a host".to_string(), + )); + } + let default_port = match url.scheme() { + "http" => 80, + "https" => 443, + _ => unreachable!("validated Backend URL scheme"), + }; + if url.port() == Some(default_port) { + url.set_port(None).map_err(|()| { + BackendApiClientError::InvalidBackendOrigin( + "Backend URL contains an invalid port".to_string(), + ) + })?; + } + url.set_path(""); + url.set_query(None); + url.set_fragment(None); + let normalized = url.as_str().trim_end_matches('/').to_string(); + Ok(Self(normalized)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + fn url(&self, path_and_query: &str) -> Result { + if !path_and_query.starts_with('/') || path_and_query.starts_with("//") { + return Err(BackendApiClientError::InvalidRequestPath( + "Backend API request path must start with one `/`".to_string(), + )); + } + Url::parse(&format!("{}{path_and_query}", self.0)).map_err(|error| { + BackendApiClientError::InvalidRequestPath(format!( + "Backend API request path is invalid: {error}" + )) + }) + } +} + +impl fmt::Debug for BackendOrigin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("BackendOrigin").field(&self.0).finish() + } +} + +impl fmt::Display for BackendOrigin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.0) + } +} + +#[derive(Clone)] +struct BackendAccessToken(String); + +impl fmt::Debug for BackendAccessToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("BackendAccessToken([REDACTED])") + } +} + +#[derive(Clone)] +pub struct BackendApiClient { + origin: BackendOrigin, + access_token: BackendAccessToken, + asynchronous: reqwest::Client, +} + +impl fmt::Debug for BackendApiClient { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("BackendApiClient") + .field("origin", &self.origin) + .field("access_token", &self.access_token) + .finish_non_exhaustive() + } +} + +impl BackendApiClient { + pub fn from_stored_token(base_url: &str) -> Result { + let path = backend_token_file_path()?; + Self::from_token_file(base_url, &path) + } + + fn from_token_file(base_url: &str, path: &Path) -> Result { + let origin = BackendOrigin::parse(base_url)?; + let token_file = read_token_file(path)?; + let entry = token_file.tokens.get(origin.as_str()).ok_or_else(|| { + BackendApiClientError::TokenEntryMissing { + origin: origin.clone(), + path: path.to_path_buf(), + } + })?; + validate_token_entry(entry, &origin, path)?; + Self::new(origin, BackendAccessToken(entry.access_token.clone())) + } + + fn new( + origin: BackendOrigin, + access_token: BackendAccessToken, + ) -> Result { + let asynchronous = reqwest::Client::builder() + .redirect(redirect_policy(origin.clone())) + .build() + .map_err(BackendApiClientError::Http)?; + Ok(Self { + origin, + access_token, + asynchronous, + }) + } + + pub fn origin(&self) -> &BackendOrigin { + &self.origin + } + + pub fn request( + &self, + method: Method, + path_and_query: &str, + ) -> Result { + let url = self.origin.url(path_and_query)?; + Ok(self + .asynchronous + .request(method, url) + .bearer_auth(&self.access_token.0)) + } + + pub fn blocking_request( + &self, + method: Method, + path_and_query: &str, + ) -> Result { + let url = self.origin.url(path_and_query)?; + let client = reqwest::blocking::Client::builder() + .redirect(redirect_policy(self.origin.clone())) + .build() + .map_err(BackendApiClientError::Http)?; + Ok(client + .request(method, url) + .bearer_auth(&self.access_token.0)) + } + + pub(crate) fn authorization_header_value(&self) -> String { + format!("Bearer {}", self.access_token.0) + } + + pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> { + match status { + StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized { + origin: self.origin.clone(), + }), + StatusCode::FORBIDDEN => Err(BackendApiClientError::Forbidden { + origin: self.origin.clone(), + }), + status if !status.is_success() => Err(BackendApiClientError::BackendStatus { + origin: self.origin.clone(), + status: status.as_u16(), + }), + _ => Ok(()), + } + } + + #[cfg(test)] + pub(crate) fn from_access_token_for_test( + base_url: &str, + access_token: &str, + ) -> Result { + Self::new( + BackendOrigin::parse(base_url)?, + BackendAccessToken(access_token.to_string()), + ) + } +} + +fn redirect_policy(origin: BackendOrigin) -> redirect::Policy { + redirect::Policy::custom(move |attempt| { + if attempt.previous().len() >= MAX_REDIRECTS { + return attempt.error("Backend request exceeded the redirect limit"); + } + match BackendOrigin::from_url(attempt.url().clone()) { + Ok(target_origin) if target_origin == origin => attempt.follow(), + Ok(target_origin) => attempt.error(format!( + "Backend request refused a cross-origin redirect from {origin} to {target_origin}" + )), + Err(error) => attempt.error(error.to_string()), + } + }) +} + +#[derive(Debug)] +pub enum BackendApiClientError { + InvalidBackendOrigin(String), + InvalidRequestPath(String), + ConfigDirectoryUnavailable, + TokenFileMissing { + path: PathBuf, + }, + TokenFileMalformed { + path: PathBuf, + message: String, + }, + TokenEntryMissing { + origin: BackendOrigin, + path: PathBuf, + }, + TokenExpired { + origin: BackendOrigin, + expired_at: String, + }, + Http(reqwest::Error), + Unauthorized { + origin: BackendOrigin, + }, + Forbidden { + origin: BackendOrigin, + }, + BackendStatus { + origin: BackendOrigin, + status: u16, + }, + Io { + path: PathBuf, + source: std::io::Error, + }, +} + +impl fmt::Display for BackendApiClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidBackendOrigin(message) | Self::InvalidRequestPath(message) => { + f.write_str(message) + } + Self::ConfigDirectoryUnavailable => f.write_str( + "cannot locate the client configuration directory for backend-tokens.json", + ), + Self::TokenFileMissing { path } => write!( + f, + "Backend token file {} is missing; run `yoi login --backend ` first", + path.display() + ), + Self::TokenFileMalformed { path, message } => write!( + f, + "Backend token file {} is malformed: {message}; run `yoi login --backend ` again", + path.display() + ), + Self::TokenEntryMissing { origin, path } => write!( + f, + "no Backend token for {origin} exists in {}; login URLs are matched by normalized origin, so run `yoi login --backend {origin}`", + path.display() + ), + Self::TokenExpired { origin, expired_at } => write!( + f, + "Backend token for {origin} expired at {expired_at}; run `yoi login --backend {origin}` again" + ), + Self::Http(error) => write!(f, "Backend request failed: {error}"), + Self::Unauthorized { origin } => write!( + f, + "Backend {origin} returned HTTP 401 for the saved token; it may be expired or revoked, so run `yoi login --backend {origin}` again" + ), + Self::Forbidden { origin } => write!( + f, + "Backend {origin} returned HTTP 403; the saved token is authenticated but is not authorized for this operation" + ), + Self::BackendStatus { origin, status } => { + write!(f, "Backend {origin} returned HTTP {status}") + } + Self::Io { path, source } => { + write!(f, "failed to access {}: {source}", path.display()) + } + } + } +} + +impl std::error::Error for BackendApiClientError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Http(error) => Some(error), + Self::Io { source, .. } => Some(source), + _ => None, + } + } +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct BackendTokenFile { + tokens: BTreeMap, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct BackendTokenEntry { + token_type: String, + access_token: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + expires_at: Option, +} + +pub fn save_backend_token( + base_url: &str, + token_type: &str, + access_token: &str, +) -> Result { + save_backend_token_with_expiry(base_url, token_type, access_token, None) +} + +fn save_backend_token_with_expiry( + base_url: &str, + token_type: &str, + access_token: &str, + expires_at: Option, +) -> Result { + let path = backend_token_file_path()?; + save_backend_token_to_file(base_url, token_type, access_token, expires_at, &path)?; + Ok(path) +} + +fn save_backend_token_to_file( + base_url: &str, + token_type: &str, + access_token: &str, + expires_at: Option, + path: &Path, +) -> Result<(), BackendApiClientError> { + let origin = BackendOrigin::parse(base_url)?; + let mut token_file = if path.exists() { + read_token_file(&path)? + } else { + BackendTokenFile { + tokens: BTreeMap::new(), + } + }; + let entry = BackendTokenEntry { + token_type: token_type.to_string(), + access_token: access_token.to_string(), + expires_at, + }; + validate_token_entry(&entry, &origin, path)?; + token_file.tokens.insert(origin.to_string(), entry); + write_token_file(path, &token_file)?; + Ok(()) +} + +pub fn backend_token_file_path() -> Result { + if let Some(config_home) = env::var_os("XDG_CONFIG_HOME") { + return Ok(PathBuf::from(config_home).join("yoi").join(TOKEN_FILE_NAME)); + } + let Some(home) = env::var_os("HOME") else { + return Err(BackendApiClientError::ConfigDirectoryUnavailable); + }; + Ok(PathBuf::from(home) + .join(".config") + .join("yoi") + .join(TOKEN_FILE_NAME)) +} + +fn read_token_file(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|source| { + if source.kind() == std::io::ErrorKind::NotFound { + BackendApiClientError::TokenFileMissing { + path: path.to_path_buf(), + } + } else { + BackendApiClientError::Io { + path: path.to_path_buf(), + source, + } + } + })?; + let raw: BackendTokenFile = serde_json::from_slice(&bytes).map_err(|error| { + BackendApiClientError::TokenFileMalformed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + normalize_token_file(raw, path) +} + +fn normalize_token_file( + token_file: BackendTokenFile, + path: &Path, +) -> Result { + let mut normalized = BTreeMap::new(); + for (raw_origin, entry) in token_file.tokens { + let origin = BackendOrigin::parse(&raw_origin).map_err(|error| { + BackendApiClientError::TokenFileMalformed { + path: path.to_path_buf(), + message: format!("token key `{raw_origin}` is invalid: {error}"), + } + })?; + if normalized.insert(origin.to_string(), entry).is_some() { + return Err(BackendApiClientError::TokenFileMalformed { + path: path.to_path_buf(), + message: format!("more than one token entry normalizes to `{origin}`"), + }); + } + } + Ok(BackendTokenFile { tokens: normalized }) +} + +fn validate_token_entry( + entry: &BackendTokenEntry, + origin: &BackendOrigin, + path: &Path, +) -> Result<(), BackendApiClientError> { + if !entry.token_type.eq_ignore_ascii_case("Bearer") { + return Err(BackendApiClientError::TokenFileMalformed { + path: path.to_path_buf(), + message: format!("token for `{origin}` does not use the Bearer token type"), + }); + } + if entry.access_token.trim().is_empty() + || entry.access_token.contains('\r') + || entry.access_token.contains('\n') + { + return Err(BackendApiClientError::TokenFileMalformed { + path: path.to_path_buf(), + message: format!("token for `{origin}` is empty or contains an invalid line break"), + }); + } + if reqwest::header::HeaderValue::from_str(&format!("Bearer {}", entry.access_token)).is_err() { + return Err(BackendApiClientError::TokenFileMalformed { + path: path.to_path_buf(), + message: format!("token for `{origin}` cannot be represented as an HTTP header"), + }); + } + if let Some(expires_at) = entry.expires_at.as_deref() { + let expiration = DateTime::parse_from_rfc3339(expires_at).map_err(|error| { + BackendApiClientError::TokenFileMalformed { + path: path.to_path_buf(), + message: format!("token for `{origin}` has invalid expires_at: {error}"), + } + })?; + if expiration <= Utc::now() { + return Err(BackendApiClientError::TokenExpired { + origin: origin.clone(), + expired_at: expires_at.to_string(), + }); + } + } + Ok(()) +} + +fn write_token_file( + path: &Path, + token_file: &BackendTokenFile, +) -> Result<(), BackendApiClientError> { + let parent = path + .parent() + .ok_or(BackendApiClientError::ConfigDirectoryUnavailable)?; + fs::create_dir_all(parent).map_err(|source| BackendApiClientError::Io { + path: parent.to_path_buf(), + source, + })?; + let payload = serde_json::to_vec_pretty(token_file).map_err(|error| { + BackendApiClientError::TokenFileMalformed { + path: path.to_path_buf(), + message: error.to_string(), + } + })?; + let temp_path = parent.join(format!(".{TOKEN_FILE_NAME}.tmp-{}", std::process::id())); + let mut options = OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temp_path) + .map_err(|source| BackendApiClientError::Io { + path: temp_path.clone(), + source, + })?; + file.write_all(&payload) + .and_then(|()| file.write_all(b"\n")) + .and_then(|()| file.sync_all()) + .map_err(|source| BackendApiClientError::Io { + path: temp_path.clone(), + source, + })?; + fs::rename(&temp_path, path).map_err(|source| BackendApiClientError::Io { + path: path.to_path_buf(), + source, + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + fn temp_path(label: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + env::temp_dir().join(format!( + "yoi-client-{label}-{}-{nonce}.json", + std::process::id() + )) + } + + fn write_fixture(path: &Path, value: serde_json::Value) { + fs::write(path, serde_json::to_vec(&value).unwrap()).unwrap(); + } + + #[test] + fn backend_origin_normalizes_safe_equivalents() { + let variants = [ + "HTTP://Example.COM", + "http://example.com/", + "http://example.com:80////", + ]; + for variant in variants { + assert_eq!( + BackendOrigin::parse(variant).unwrap().as_str(), + "http://example.com" + ); + } + assert_eq!( + BackendOrigin::parse("https://EXAMPLE.com:443/") + .unwrap() + .as_str(), + "https://example.com" + ); + assert_eq!( + BackendOrigin::parse("https://example.com:8443/") + .unwrap() + .as_str(), + "https://example.com:8443" + ); + } + + #[test] + fn backend_origin_rejects_unsafe_authority_changes() { + for invalid in [ + "ftp://example.com", + "https://user@example.com", + "https://example.com/api", + "https://example.com/?query=1", + "https://example.com/#fragment", + ] { + assert!(BackendOrigin::parse(invalid).is_err(), "accepted {invalid}"); + } + assert_ne!( + BackendOrigin::parse("http://localhost:8787").unwrap(), + BackendOrigin::parse("http://127.0.0.1:8787").unwrap() + ); + } + + #[test] + fn token_lookup_distinguishes_missing_malformed_mismatch_and_expired() { + let missing = temp_path("missing"); + assert!(matches!( + BackendApiClient::from_token_file("http://localhost:8787", &missing), + Err(BackendApiClientError::TokenFileMissing { .. }) + )); + + let malformed = temp_path("malformed"); + fs::write(&malformed, b"not json").unwrap(); + assert!(matches!( + BackendApiClient::from_token_file("http://localhost:8787", &malformed), + Err(BackendApiClientError::TokenFileMalformed { .. }) + )); + + let mismatch = temp_path("mismatch"); + write_fixture( + &mismatch, + serde_json::json!({"tokens": {"http://localhost:8787": { + "token_type": "Bearer", "access_token": "secret" + }}}), + ); + assert!(matches!( + BackendApiClient::from_token_file("http://127.0.0.1:8787", &mismatch), + Err(BackendApiClientError::TokenEntryMissing { .. }) + )); + + let expired = temp_path("expired"); + write_fixture( + &expired, + serde_json::json!({"tokens": {"http://localhost:8787": { + "token_type": "Bearer", + "access_token": "secret", + "expires_at": "2000-01-01T00:00:00Z" + }}}), + ); + assert!(matches!( + BackendApiClient::from_token_file("http://localhost:8787", &expired), + Err(BackendApiClientError::TokenExpired { .. }) + )); + + for path in [malformed, mismatch, expired] { + let _ = fs::remove_file(path); + } + } + + #[test] + fn token_write_and_lookup_share_origin_normalization() { + let path = temp_path("normalized-write"); + save_backend_token_to_file( + "HTTP://Example.COM:80////", + "Bearer", + "normalized-secret", + None, + &path, + ) + .unwrap(); + let contents = fs::read_to_string(&path).unwrap(); + assert!(contents.contains("\"http://example.com\"")); + let client = BackendApiClient::from_token_file("http://example.com/", &path).unwrap(); + assert_eq!( + client.authorization_header_value(), + "Bearer normalized-secret" + ); + fs::remove_file(path).unwrap(); + } + + #[test] + fn client_debug_and_errors_never_include_token_value() { + let client = BackendApiClient::from_access_token_for_test( + "http://localhost:8787", + "never-print-this-token", + ) + .unwrap(); + assert!(!format!("{client:?}").contains("never-print-this-token")); + assert!( + !BackendApiClientError::Unauthorized { + origin: client.origin().clone() + } + .to_string() + .contains("never-print-this-token") + ); + } + + #[test] + fn authenticated_requests_follow_only_same_origin_redirects() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let origin = format!("http://{}", listener.local_addr().unwrap()); + let handle = thread::spawn(move || { + for response in [ + "HTTP/1.1 302 Found\r\nLocation: /final\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ] { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = vec![0; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + assert!(request.contains("authorization: bearer redirect-secret\r\n")); + stream.write_all(response.as_bytes()).unwrap(); + } + }); + let client = + BackendApiClient::from_access_token_for_test(&origin, "redirect-secret").unwrap(); + let response = client + .blocking_request(Method::GET, "/start") + .unwrap() + .send() + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + handle.join().unwrap(); + } + + #[test] + fn authenticated_requests_reject_cross_origin_redirects_without_leaking_token() { + let source = TcpListener::bind("127.0.0.1:0").unwrap(); + let target = TcpListener::bind("127.0.0.1:0").unwrap(); + target.set_nonblocking(true).unwrap(); + let source_origin = format!("http://{}", source.local_addr().unwrap()); + let target_origin = format!("http://{}", target.local_addr().unwrap()); + let location = format!("{target_origin}/capture"); + let handle = thread::spawn(move || { + let (mut stream, _) = source.accept().unwrap(); + let mut request = vec![0; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + assert!(request.contains("authorization: bearer redirect-secret\r\n")); + let response = format!( + "HTTP/1.1 302 Found\r\nLocation: {location}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ); + stream.write_all(response.as_bytes()).unwrap(); + }); + let client = + BackendApiClient::from_access_token_for_test(&source_origin, "redirect-secret") + .unwrap(); + let error = client + .blocking_request(Method::GET, "/start") + .unwrap() + .send() + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("redirect")); + assert!(!message.contains("redirect-secret")); + handle.join().unwrap(); + thread::sleep(Duration::from_millis(20)); + assert!(matches!( + target.accept(), + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock + )); + } + + #[test] + fn status_diagnostics_distinguish_unauthorized_and_forbidden() { + let client = + BackendApiClient::from_access_token_for_test("http://localhost:8787", "secret") + .unwrap(); + assert!(matches!( + client.check_status(StatusCode::UNAUTHORIZED), + Err(BackendApiClientError::Unauthorized { .. }) + )); + assert!(matches!( + client.check_status(StatusCode::FORBIDDEN), + Err(BackendApiClientError::Forbidden { .. }) + )); + } +} diff --git a/crates/client/src/backend_auth.rs b/crates/client/src/backend_auth.rs index 78cd1097..8f4c1392 100644 --- a/crates/client/src/backend_auth.rs +++ b/crates/client/src/backend_auth.rs @@ -1,3 +1,4 @@ +use crate::BackendOrigin; use serde::{Deserialize, Serialize}; use std::fmt; use std::time::Duration; @@ -9,9 +10,11 @@ pub struct BackendAuthTarget { impl BackendAuthTarget { pub fn new(base_url: impl Into) -> Self { - Self { - base_url: base_url.into(), - } + let base_url = base_url.into(); + let base_url = BackendOrigin::parse(&base_url) + .map(|origin| origin.to_string()) + .unwrap_or(base_url); + Self { base_url } } fn api_url(&self, path: &str) -> String { diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 9ec12f6c..8cb67625 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -1,11 +1,16 @@ +use crate::{BackendApiClient, BackendApiClientError}; use futures::{SinkExt, StreamExt}; use protocol::stream::{decode_event, encode_method}; use protocol::{ErrorCode, Event, Method}; +use reqwest::Method as HttpMethod; use std::collections::VecDeque; use std::fmt; use tokio::sync::mpsc; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; +use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tokio_tungstenite::tungstenite::http::HeaderValue; +use tokio_tungstenite::tungstenite::http::header::AUTHORIZATION; pub use workdir::workspace::WorkingDirectorySummary as BackendWorkingDirectorySummary; pub use workspace_api::{ Diagnostic as BackendDiagnostic, DiagnosticSeverity as BackendDiagnosticSeverity, @@ -113,6 +118,7 @@ pub struct BackendRuntimeClient { #[derive(Debug)] pub enum BackendRuntimeClientError { InvalidTarget(String), + Api(BackendApiClientError), Http(reqwest::Error), } @@ -120,6 +126,7 @@ impl fmt::Display for BackendRuntimeClientError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidTarget(message) => f.write_str(message), + Self::Api(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"), } } @@ -127,6 +134,12 @@ impl fmt::Display for BackendRuntimeClientError { impl std::error::Error for BackendRuntimeClientError {} +impl From for BackendRuntimeClientError { + fn from(error: BackendApiClientError) -> Self { + Self::Api(error) + } +} + impl From for BackendRuntimeClientError { fn from(error: reqwest::Error) -> Self { Self::Http(error) @@ -137,7 +150,7 @@ pub async fn list_backend_workers( target: &BackendRuntimeListTarget, ) -> Result, BackendRuntimeClientError> { validate_list_target(target)?; - let http = reqwest::Client::new(); + let api = BackendApiClient::from_stored_token(&target.base_url)?; if let Some(runtime_id) = target.runtime_id.as_deref() { let path = backend_runtime_workers_path( target @@ -146,12 +159,9 @@ pub async fn list_backend_workers( .expect("validated Backend Workspace scope"), runtime_id, ); - let url = join_base_and_path(&target.base_url, &path); - return Ok(http - .get(url) - .send() - .await? - .error_for_status()? + let response = api.request(HttpMethod::GET, &path)?.send().await?; + api.check_status(response.status())?; + return Ok(response .json::>() .await?); } @@ -162,12 +172,9 @@ pub async fn list_backend_workers( .as_deref() .expect("validated Backend Workspace scope"), ); - let runtime_url = join_base_and_path(&target.base_url, &runtime_path); - let runtimes = http - .get(runtime_url) - .send() - .await? - .error_for_status()? + let response = api.request(HttpMethod::GET, &runtime_path)?.send().await?; + api.check_status(response.status())?; + let runtimes = response .json::>() .await?; @@ -181,29 +188,43 @@ pub async fn list_backend_workers( .expect("validated Backend Workspace scope"), &runtime.runtime_id, ); - let url = join_base_and_path(&target.base_url, &path); - match http - .get(url) - .send() - .await - .and_then(|response| response.error_for_status()) - { - Ok(response) => { - let response = response - .json::>() - .await?; - diagnostics.extend(response.diagnostics); - items.extend(response.items); + let response = match api.request(HttpMethod::GET, &path)?.send().await { + Ok(response) => response, + Err(error) => { + diagnostics.push(BackendDiagnostic { + code: "runtime_worker_list_failed".to_string(), + severity: BackendDiagnosticSeverity::Error, + message: format!( + "failed to list workers for runtime {}: {error}", + runtime.runtime_id + ), + }); + continue; } - Err(error) => diagnostics.push(BackendDiagnostic { + }; + if matches!( + response.status(), + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN + ) { + api.check_status(response.status())?; + } + if !response.status().is_success() { + diagnostics.push(BackendDiagnostic { code: "runtime_worker_list_failed".to_string(), severity: BackendDiagnosticSeverity::Error, message: format!( - "failed to list workers for runtime {}: {error}", - runtime.runtime_id + "failed to list workers for runtime {}: Backend returned HTTP {}", + runtime.runtime_id, + response.status().as_u16() ), - }), + }); + continue; } + let response = response + .json::>() + .await?; + diagnostics.extend(response.diagnostics); + items.extend(response.items); } Ok(BackendRuntimeListResponse { @@ -224,7 +245,7 @@ pub async fn list_backend_stopped_workers( "stopped worker listing requires a runtime id".to_string(), )); }; - let http = reqwest::Client::new(); + let api = BackendApiClient::from_stored_token(&target.base_url)?; let path = backend_runtime_workers_path( target .workspace_id @@ -232,12 +253,12 @@ pub async fn list_backend_stopped_workers( .expect("validated Backend Workspace scope"), runtime_id, ); - let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped")); - Ok(http - .get(url) + let response = api + .request(HttpMethod::GET, &format!("{path}?status=stopped"))? .send() - .await? - .error_for_status()? + .await?; + api.check_status(response.status())?; + Ok(response .json::>() .await?) } @@ -246,33 +267,33 @@ pub async fn restore_backend_worker( target: &BackendRuntimeTarget, ) -> Result { validate_target(target)?; - let http = reqwest::Client::new(); + let api = BackendApiClient::from_stored_token(&target.base_url)?; let path = backend_runtime_worker_restore_path( &target.workspace_id, &target.runtime_id, &target.worker_id, ); - let url = join_base_and_path(&target.base_url, &path); - Ok(http - .post(url) + let response = api + .request(HttpMethod::POST, &path)? .json(&serde_json::json!({})) .send() - .await? - .error_for_status()? - .json::() - .await?) + .await?; + api.check_status(response.status())?; + Ok(response.json::().await?) } impl BackendRuntimeClient { pub async fn connect(target: BackendRuntimeTarget) -> Result { validate_target(&target)?; + let api = BackendApiClient::from_stored_token(&target.base_url)?; let (event_tx, rx) = mpsc::unbounded_channel(); let (command_tx, command_rx) = mpsc::unbounded_channel(); let protocol_target = target.clone(); let protocol_event_tx = event_tx.clone(); let protocol_task = tokio::spawn(async move { - run_worker_protocol_transport(protocol_target, command_rx, protocol_event_tx).await; + run_worker_protocol_transport(protocol_target, api, command_rx, protocol_event_tx) + .await; }); Ok(Self { @@ -317,11 +338,21 @@ impl Drop for BackendRuntimeClient { async fn run_worker_protocol_transport( target: BackendRuntimeTarget, + api: BackendApiClient, mut commands: mpsc::UnboundedReceiver, tx: mpsc::UnboundedSender, ) { - let url = protocol_ws_url(&target); - match connect_async(&url).await { + let request = match protocol_ws_request(&target, &api) { + Ok(request) => request, + Err(error) => { + let _ = tx.send(diagnostic_event(format!( + "Backend protocol request could not be constructed for {}: {error}", + target.display_label() + ))); + return; + } + }; + match connect_async(request).await { Ok((ws, _)) => { let (mut sink, mut stream) = ws.split(); loop { @@ -387,10 +418,8 @@ async fn run_worker_protocol_transport( } } Err(error) => { - let _ = tx.send(diagnostic_event(format!( - "Backend protocol WebSocket connect failed for {}: {error}", - target.display_label() - ))); + let message = protocol_connect_error_message(&target, &api, &error); + let _ = tx.send(diagnostic_event(message)); while commands.recv().await.is_some() { let _ = tx.send(diagnostic_event(format!( "Backend protocol command was not sent because command stream is unavailable for {}", @@ -401,6 +430,29 @@ async fn run_worker_protocol_transport( } } +fn protocol_connect_error_message( + target: &BackendRuntimeTarget, + api: &BackendApiClient, + error: &tokio_tungstenite::tungstenite::Error, +) -> String { + if let tokio_tungstenite::tungstenite::Error::Http(response) = error { + if let Ok(status) = reqwest::StatusCode::from_u16(response.status().as_u16()) { + if matches!( + status, + reqwest::StatusCode::UNAUTHORIZED | reqwest::StatusCode::FORBIDDEN + ) { + if let Err(error) = api.check_status(status) { + return error.to_string(); + } + } + } + } + format!( + "Backend protocol WebSocket connect failed for {}: {error}", + target.display_label() + ) +} + fn diagnostic_event(message: impl Into) -> Event { Event::Error { code: ErrorCode::Internal, @@ -496,6 +548,19 @@ fn backend_runtime_worker_restore_path( ) } +fn protocol_ws_request( + target: &BackendRuntimeTarget, + api: &BackendApiClient, +) -> Result, String> { + let mut request = protocol_ws_url(target) + .into_client_request() + .map_err(|error| error.to_string())?; + let value = HeaderValue::from_str(&api.authorization_header_value()) + .map_err(|_| "saved Backend token is not a valid Authorization header".to_string())?; + request.headers_mut().insert(AUTHORIZATION, value); + Ok(request) +} + fn protocol_ws_url(target: &BackendRuntimeTarget) -> String { let path = format!( "/api/w/{}/runtimes/{}/workers/{}/protocol/ws", @@ -557,6 +622,26 @@ mod tests { ); } + #[test] + fn protocol_request_attaches_saved_bearer_authorization() { + let target = BackendRuntimeTarget::new( + "http://127.0.0.1:8787/", + "workspace alpha", + "runtime/one", + "worker one", + ); + let api = BackendApiClient::from_access_token_for_test( + "http://127.0.0.1:8787", + "websocket-secret", + ) + .unwrap(); + let request = protocol_ws_request(&target, &api).unwrap(); + assert_eq!( + request.headers().get(AUTHORIZATION).unwrap(), + "Bearer websocket-secret" + ); + } + #[test] fn backend_worker_summary_decodes_current_occupied_workdir_contract() { let payload = serde_json::json!({ diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index 739b50d4..94ce5afb 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -1,3 +1,5 @@ +use crate::{BackendApiClient, BackendApiClientError}; +use reqwest::Method; use serde::{Deserialize, Serialize}; use std::fmt; use workspace_api::{RepositoryObservedStatus, RepositorySource}; @@ -70,7 +72,7 @@ impl BackendWorkspaceCatalogTarget { #[derive(Debug)] pub enum BackendWorkspaceClientError { InvalidTarget(String), - RequestFailed { status: u16, message: String }, + Api(BackendApiClientError), Http(reqwest::Error), } @@ -78,9 +80,7 @@ impl fmt::Display for BackendWorkspaceClientError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::InvalidTarget(message) => f.write_str(message), - Self::RequestFailed { status, message } => { - write!(f, "Backend request failed with HTTP {status}: {message}") - } + Self::Api(error) => write!(f, "{error}"), Self::Http(error) => write!(f, "{error}"), } } @@ -88,6 +88,12 @@ impl fmt::Display for BackendWorkspaceClientError { impl std::error::Error for BackendWorkspaceClientError {} +impl From for BackendWorkspaceClientError { + fn from(error: BackendApiClientError) -> Self { + Self::Api(error) + } +} + impl From for BackendWorkspaceClientError { fn from(error: reqwest::Error) -> Self { Self::Http(error) @@ -97,13 +103,21 @@ impl From for BackendWorkspaceClientError { pub async fn list_backend_workspaces( target: &BackendWorkspaceCatalogTarget, ) -> Result, BackendWorkspaceClientError> { - validate_target(target)?; - let url = format!( - "{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}", - target.base_url.trim_end_matches('/') - ); - let response = reqwest::Client::new().get(url).send().await?; - let response = require_success(response).await?; + let client = BackendApiClient::from_stored_token(&target.base_url)?; + list_backend_workspaces_with_client(&client).await +} + +async fn list_backend_workspaces_with_client( + client: &BackendApiClient, +) -> Result, BackendWorkspaceClientError> { + let response = client + .request( + Method::GET, + &format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"), + )? + .send() + .await?; + client.check_status(response.status())?; Ok(response.json::>().await?) } @@ -111,42 +125,50 @@ pub async fn create_backend_workspace( target: &BackendWorkspaceCatalogTarget, request: &CreateBackendWorkspaceRequest, ) -> Result { - validate_target(target)?; - let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/')); - let response = reqwest::Client::new() - .post(url) + let client = BackendApiClient::from_stored_token(&target.base_url)?; + let response = client + .request(Method::POST, "/api/workspaces")? .json(request) .send() .await?; - let response = require_success(response).await?; + client.check_status(response.status())?; Ok(response.json::().await?) } -async fn require_success( - response: reqwest::Response, -) -> Result { - if response.status().is_success() { - return Ok(response); - } - let status = response.status().as_u16(); - let message = response.text().await.unwrap_or_default(); - Err(BackendWorkspaceClientError::RequestFailed { status, message }) -} - -fn validate_target( - target: &BackendWorkspaceCatalogTarget, -) -> Result<(), BackendWorkspaceClientError> { - if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) { - return Err(BackendWorkspaceClientError::InvalidTarget( - "Backend API base URL must start with http:// or https://".to_string(), - )); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + use std::thread; + + #[tokio::test] + async fn workspace_catalog_request_uses_shared_bearer_client() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let handle = thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut request = vec![0; 4096]; + let read = stream.read(&mut request).unwrap(); + let request = String::from_utf8_lossy(&request[..read]).to_ascii_lowercase(); + assert!(request.starts_with("get /api/workspaces?limit=200 ")); + assert!(request.contains("authorization: bearer catalog-secret\r\n")); + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n[]", + ) + .unwrap(); + }); + let client = + BackendApiClient::from_access_token_for_test(&base_url, "catalog-secret").unwrap(); + assert!( + list_backend_workspaces_with_client(&client) + .await + .unwrap() + .is_empty() + ); + handle.join().unwrap(); + } #[test] fn create_request_keeps_operation_key_for_exact_retry() { diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index 4ea7a11a..9063ba5e 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -8,7 +8,8 @@ //! //! TUI / GUI / E2E ハーネスはこの crate に依存して protocol を喋る。 -pub mod backend_auth; +pub mod backend_api; +mod backend_auth; pub mod backend_runtime; pub mod backend_workspace; pub mod runtime_command; @@ -18,6 +19,10 @@ pub mod ticket_role; mod worker_client; mod workspace_product; +pub use backend_api::{ + BackendApiClient, BackendApiClientError, BackendOrigin, backend_token_file_path, + save_backend_token, +}; pub use backend_auth::{ BackendAuthClientError, BackendAuthTarget, DeviceLoginPollResponse, DeviceLoginStartResponse, poll_device_login, start_device_login, wait_for_device_login, diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index 1b98819f..21b8efa0 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -1,6 +1,9 @@ use std::fmt; -use crate::{BackendRuntimeListTarget, BackendRuntimeTarget, WorkerRuntimeCommand}; +use crate::{ + BackendApiClient, BackendApiClientError, BackendOrigin, BackendRuntimeListTarget, + BackendRuntimeTarget, WorkerRuntimeCommand, +}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum TargetKind { @@ -62,11 +65,19 @@ pub struct BackendTarget { impl BackendTarget { pub fn new(base_url: impl Into, workspace_id: Option>) -> Self { + let base_url = base_url.into(); + let base_url = BackendOrigin::parse(&base_url) + .map(|origin| origin.to_string()) + .unwrap_or(base_url); Self { - base_url: base_url.into(), + base_url, workspace_id: workspace_id.map(Into::into), } } + + pub fn authenticated_client(&self) -> Result { + BackendApiClient::from_stored_token(&self.base_url) + } } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 599344e0..fb96a602 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -14,7 +14,7 @@ use workspace_api::{ TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, }; -use crate::BackendWorkspaceClientError; +use crate::{BackendApiClient, BackendWorkspaceClientError}; const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000; @@ -47,9 +47,9 @@ struct BackendWorkspaceOrchestratorResponse { /// Construction requires both the selected Backend URL and Workspace identity. /// Callers should derive these once from `Target::resolve()` and must not retry /// failed requests against repository-local state. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone)] pub struct BackendWorkspaceProductClient { - base_url: String, + api: BackendApiClient, workspace_id: String, } @@ -58,22 +58,32 @@ impl BackendWorkspaceProductClient { base_url: impl Into, workspace_id: impl Into, ) -> Result { - let base_url = base_url.into().trim_end_matches('/').to_string(); - if base_url.is_empty() { - return Err(BackendWorkspaceClientError::InvalidTarget( - "Backend base URL must not be empty".into(), - )); - } + let base_url = base_url.into(); + let api = BackendApiClient::from_stored_token(&base_url)?; let workspace_id = workspace_id.into(); if workspace_id.trim().is_empty() { return Err(BackendWorkspaceClientError::InvalidTarget( "Backend Workspace identity must not be empty".into(), )); } - Ok(Self { - base_url, - workspace_id, - }) + Ok(Self { api, workspace_id }) + } + + #[cfg(test)] + fn new_with_access_token( + base_url: impl Into, + workspace_id: impl Into, + access_token: &str, + ) -> Result { + let base_url = base_url.into(); + let api = BackendApiClient::from_access_token_for_test(&base_url, access_token)?; + let workspace_id = workspace_id.into(); + if workspace_id.trim().is_empty() { + return Err(BackendWorkspaceClientError::InvalidTarget( + "Backend Workspace identity must not be empty".into(), + )); + } + Ok(Self { api, workspace_id }) } pub fn workspace_id(&self) -> &str { @@ -316,7 +326,7 @@ impl BackendWorkspaceProductClient { body: Option<&B>, ) -> Result { let response = self.request(method, path, body)?.send()?; - let response = ensure_success(response)?; + self.api.check_status(response.status())?; response.json().map_err(BackendWorkspaceClientError::Http) } @@ -326,7 +336,8 @@ impl BackendWorkspaceProductClient { path: &str, body: Option<&B>, ) -> Result<(), BackendWorkspaceClientError> { - ensure_success(self.request(method, path, body)?.send()?)?; + let response = self.request(method, path, body)?.send()?; + self.api.check_status(response.status())?; Ok(()) } @@ -336,14 +347,12 @@ impl BackendWorkspaceProductClient { path: &str, body: Option<&B>, ) -> Result { - let client = reqwest::blocking::Client::builder().build()?; - let url = format!( - "{}/api/w/{}/{}", - self.base_url, + let path = format!( + "/api/w/{}/{}", encode_path_segment(&self.workspace_id), path.trim_start_matches('/') ); - let request = client.request(method, url); + let request = self.api.blocking_request(method, &path)?; Ok(match body { Some(body) => request.json(body), None => request, @@ -588,19 +597,6 @@ fn ticket_client_error(error: BackendWorkspaceClientError) -> TicketError { TicketError::Sqlite(format!("Backend request failed: {error}")) } -fn ensure_success( - response: reqwest::blocking::Response, -) -> Result { - if response.status().is_success() { - return Ok(response); - } - let status = response.status().as_u16(); - let message = response - .text() - .unwrap_or_else(|_| "Backend request failed".to_string()); - Err(BackendWorkspaceClientError::RequestFailed { status, message }) -} - fn ticket_reference(id: &TicketIdOrSlug) -> String { match id { TicketIdOrSlug::Id(id) => id.to_string(), @@ -698,24 +694,32 @@ mod tests { fn objective_list_uses_workspace_scoped_backend_route() { let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#; let (base_url, request, handle) = one_response_server("200 OK", body); - let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-backend-token", + ) + .unwrap(); let response = client.list_objectives(1_000).unwrap(); assert!(response.items.is_empty()); - assert!( - request - .recv() - .unwrap() - .starts_with("GET /api/w/workspace-a/objectives?limit=1000 ") - ); + let request = request.recv().unwrap(); + assert!(request.starts_with("GET /api/w/workspace-a/objectives?limit=1000 ")); + assert!(request.contains("authorization: Bearer test-backend-token\r\n")); handle.join().unwrap(); } #[test] fn backend_mutation_failure_is_returned_without_local_fallback() { - let (base_url, request, handle) = one_response_server("403 Forbidden", "denied"); - let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + let (base_url, request, handle) = + one_response_server("403 Forbidden", "test-backend-token"); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-backend-token", + ) + .unwrap(); let error = client .create_objective(&ObjectiveCreateRequest { @@ -727,6 +731,7 @@ mod tests { .unwrap_err(); assert!(error.to_string().contains("403")); + assert!(!error.to_string().contains("test-backend-token")); assert!( request .recv() @@ -739,7 +744,12 @@ mod tests { #[test] fn ticket_relation_query_uses_workspace_scoped_backend_route() { let (base_url, request, handle) = one_response_server("200 OK", "[]"); - let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-backend-token", + ) + .unwrap(); let relations = client .query_ticket_relations( @@ -758,7 +768,12 @@ mod tests { #[test] fn orchestration_plan_query_uses_workspace_scoped_backend_route() { let (base_url, request, handle) = one_response_server("200 OK", "[]"); - let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-backend-token", + ) + .unwrap(); let records = TicketBackend::query_orchestration_plan_records(&client, None, None).unwrap(); @@ -784,7 +799,12 @@ mod tests { r#"{"runtime_id":"embedded","worker_id":"worker-1"}"#, ), ]); - let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-backend-token", + ) + .unwrap(); let status = client.launch_ticket_intake("T-1").unwrap(); @@ -806,7 +826,12 @@ mod tests { fn workspace_orchestrator_launch_uses_scoped_backend_route() { let body = r#"{"disposition":"created","worker":{"runtime_id":"embedded","worker_id":"worker-2"}}"#; let (base_url, request, handle) = one_response_server("200 OK", body); - let client = BackendWorkspaceProductClient::new(base_url, "workspace-a").unwrap(); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-backend-token", + ) + .unwrap(); let status = client.start_workspace_orchestrator().unwrap(); @@ -822,7 +847,12 @@ mod tests { #[test] fn product_client_requires_workspace_identity() { - let error = BackendWorkspaceProductClient::new("http://127.0.0.1:8787", "").unwrap_err(); + let error = BackendWorkspaceProductClient::new_with_access_token( + "http://127.0.0.1:8787", + "", + "test-backend-token", + ) + .unwrap_err(); assert!(error.to_string().contains("Workspace identity")); } diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index c2f10655..54acf7b6 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -22,7 +22,7 @@ use cli_connection::{ }; use client::{BackendAuthTarget, Target, TargetKind, start_device_login, wait_for_device_login}; use memory_lint::{LintCliOptions, LintStatus}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use session_store::SegmentId; use tui::{LaunchMode, LaunchOptions}; @@ -1220,65 +1220,16 @@ async fn run_login(backend_url: &str, no_wait: bool) -> Result<(), ParseError> { ) .await .map_err(|error| ParseError(error.to_string()))?; - save_backend_token(backend_url, &token)?; - println!("Saved Backend API token for {backend_url}"); - Ok(()) -} - -#[derive(Debug, Default, Serialize, Deserialize)] -struct BackendTokenFile { - #[serde(default)] - tokens: BTreeMap, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -struct BackendTokenEntry { - token_type: String, - access_token: String, -} - -fn save_backend_token(backend_url: &str, access_token: &str) -> Result<(), ParseError> { - let path = backend_token_path().ok_or_else(|| { - ParseError("HOME or XDG_CONFIG_HOME is required to save Backend token".to_string()) - })?; - let mut file = if path.is_file() { - let contents = fs::read_to_string(&path) - .map_err(|error| ParseError(format!("failed to read {}: {error}", path.display())))?; - serde_json::from_str::(&contents) - .map_err(|error| ParseError(format!("failed to parse {}: {error}", path.display())))? - } else { - BackendTokenFile::default() - }; - file.tokens.insert( - backend_url.trim_end_matches('/').to_string(), - BackendTokenEntry { - token_type: "Bearer".to_string(), - access_token: access_token.to_string(), - }, + let token_path = client::save_backend_token(backend_url, "Bearer", &token) + .map_err(|error| ParseError(error.to_string()))?; + println!( + "Saved Backend API token for {} in {}", + client::BackendOrigin::parse(backend_url).map_err(|error| ParseError(error.to_string()))?, + token_path.display() ); - if let Some(parent) = path.parent() { - fs::create_dir_all(parent).map_err(|error| { - ParseError(format!("failed to create {}: {error}", parent.display())) - })?; - } - let serialized = serde_json::to_string_pretty(&file) - .map_err(|error| ParseError(format!("failed to serialize Backend token file: {error}")))?; - fs::write(&path, format!("{serialized}\n")) - .map_err(|error| ParseError(format!("failed to write {}: {error}", path.display())))?; Ok(()) } -fn backend_token_path() -> Option { - yoi_config_dir().map(|dir| dir.join("backend-tokens.json")) -} - -fn yoi_config_dir() -> Option { - if let Some(home) = std::env::var_os("XDG_CONFIG_HOME") { - return Some(PathBuf::from(home).join("yoi")); - } - std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config").join("yoi")) -} - fn parse_plugin_args(args: &[String]) -> Result { let Some((subcommand, rest)) = args.split_first() else { return Err(ParseError( From c0290512b3743de47201da655894179442f5b699 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 30 Aug 2026 10:15:23 +0900 Subject: [PATCH 2/2] fix: align Ticket relation projection --- .../feature/builtin/resource_projection.rs | 111 +++++++++++++++--- 1 file changed, 92 insertions(+), 19 deletions(-) diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index a169f58b..56fb9a53 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -422,27 +422,11 @@ fn project_relation( kind_key: &str, ) -> Result { let relation = object(value, "Ticket relation")?; - let relation_data = relation.get("relation").and_then(Value::as_object); - let kind = if kind_key == "kind" { - relation_data - .ok_or_else(|| "Ticket relation is missing relation data".to_string()) - .and_then(|data| string_field(data, "kind"))? - } else { - string_field(relation, kind_key)? - }; - let note = match relation_data { - Some(data) => optional_string(data, "note")?, - None => optional_string(relation, "note")?, - }; - let created_at = match relation_data { - Some(data) => optional_string(data, "at")?, - None => optional_string(relation, "at")?, - }; Ok(ModelRelation { ticket: resource_ref(relation, ticket_key, "T-")?, - kind, - note, - created_at, + kind: string_field(relation, kind_key)?, + note: optional_string(relation, "note")?, + created_at: optional_string(relation, "at")?, }) } @@ -776,6 +760,95 @@ mod tests { assert!(!objective_json.contains("00001TICKETINTERNAL")); } + #[test] + fn relation_projection_accepts_current_workspace_api_shapes() { + let outgoing = project_relation( + &json!({ + "ticket_id": "internal-source-ticket", + "kind": "depends_on", + "target": "internal-target-ticket", + "target_resource_key": "T-535", + "note": "required foundation", + "author": "internal-author", + "at": "2026-08-22T00:00:00Z" + }), + "target_resource_key", + "kind", + ) + .expect("outgoing relation projection"); + let incoming = project_relation( + &json!({ + "source_ticket": "internal-source-ticket", + "source_resource_key": "T-536", + "inverse_kind": "blocks", + "forward_kind": "depends_on", + "note": null, + "author": "internal-author", + "at": "2026-08-22T00:01:00Z" + }), + "source_resource_key", + "forward_kind", + ) + .expect("incoming relation projection"); + + let outgoing = serde_json::to_value(outgoing).expect("serialize outgoing relation"); + assert_eq!( + outgoing, + json!({ + "ticket": "T-535", + "kind": "depends_on", + "note": "required foundation", + "created_at": "2026-08-22T00:00:00Z" + }) + ); + let incoming = serde_json::to_value(incoming).expect("serialize incoming relation"); + assert_eq!( + incoming, + json!({ + "ticket": "T-536", + "kind": "depends_on", + "note": null, + "created_at": "2026-08-22T00:01:00Z" + }) + ); + let projection = format!("{outgoing}{incoming}"); + for internal in [ + "internal-source-ticket", + "internal-target-ticket", + "internal-author", + ] { + assert!(!projection.contains(internal)); + } + } + + #[test] + fn relation_projection_rejects_missing_workspace_keys() { + let outgoing = json!({ + "kind": "depends_on", + "target": "internal-target-ticket", + "note": null, + "author": "internal-author", + "at": "2026-08-22T00:00:00Z" + }); + let incoming = json!({ + "source_resource_key": "not-a-ticket-key", + "forward_kind": "depends_on", + "note": null, + "author": "internal-author", + "at": "2026-08-22T00:01:00Z" + }); + assert!( + project_relation(&outgoing, "target_resource_key", "kind") + .expect_err("missing outgoing key must fail") + .contains("T-") + ); + assert!( + project_relation(&incoming, "source_resource_key", "forward_kind") + .expect_err("invalid incoming key must fail") + .contains("T-") + ); + } + #[test] fn resource_projection_rejects_noncanonical_keys() { for (key, prefix) in [("T-key", "T-"), ("O-", "O-"), ("W-1x", "W-")] {