Merge remote-tracking branch 'origin/develop' into work/T-545-canonical-session-snapshot

This commit is contained in:
2026-08-30 09:42:57 +09:00
10 changed files with 1075 additions and 198 deletions
+1
View File
@@ -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 }
+768
View File
@@ -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<Self, BackendApiClientError> {
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<Self, BackendApiClientError> {
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<Url, BackendApiClientError> {
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<Self, BackendApiClientError> {
let path = backend_token_file_path()?;
Self::from_token_file(base_url, &path)
}
fn from_token_file(base_url: &str, path: &Path) -> Result<Self, BackendApiClientError> {
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<Self, BackendApiClientError> {
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<reqwest::RequestBuilder, BackendApiClientError> {
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<reqwest::blocking::RequestBuilder, BackendApiClientError> {
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, BackendApiClientError> {
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 <BACKEND>` first",
path.display()
),
Self::TokenFileMalformed { path, message } => write!(
f,
"Backend token file {} is malformed: {message}; run `yoi login --backend <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<String, BackendTokenEntry>,
}
#[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<String>,
}
pub fn save_backend_token(
base_url: &str,
token_type: &str,
access_token: &str,
) -> Result<PathBuf, BackendApiClientError> {
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<String>,
) -> Result<PathBuf, BackendApiClientError> {
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<String>,
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<PathBuf, BackendApiClientError> {
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<BackendTokenFile, BackendApiClientError> {
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<BackendTokenFile, BackendApiClientError> {
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 { .. })
));
}
}
+6 -3
View File
@@ -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<String>) -> 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 {
+136 -51
View File
@@ -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<BackendApiClientError> for BackendRuntimeClientError {
fn from(error: BackendApiClientError) -> Self {
Self::Api(error)
}
}
impl From<reqwest::Error> for BackendRuntimeClientError {
fn from(error: reqwest::Error) -> Self {
Self::Http(error)
@@ -137,7 +150,7 @@ pub async fn list_backend_workers(
target: &BackendRuntimeListTarget,
) -> Result<BackendRuntimeListResponse<BackendWorkerSummary>, 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::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.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::<BackendRuntimeListResponse<BackendRuntimeSummary>>()
.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::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.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::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.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::<BackendRuntimeListResponse<BackendWorkerSummary>>()
.await?)
}
@@ -246,33 +267,33 @@ pub async fn restore_backend_worker(
target: &BackendRuntimeTarget,
) -> Result<BackendWorkerRestoreResponse, BackendRuntimeClientError> {
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::<BackendWorkerRestoreResponse>()
.await?)
.await?;
api.check_status(response.status())?;
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
}
impl BackendRuntimeClient {
pub async fn connect(target: BackendRuntimeTarget) -> Result<Self, BackendRuntimeClientError> {
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<Method>,
tx: mpsc::UnboundedSender<Event>,
) {
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<String>) -> 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<tokio_tungstenite::tungstenite::http::Request<()>, 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!({
+60 -38
View File
@@ -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<BackendApiClientError> for BackendWorkspaceClientError {
fn from(error: BackendApiClientError) -> Self {
Self::Api(error)
}
}
impl From<reqwest::Error> for BackendWorkspaceClientError {
fn from(error: reqwest::Error) -> Self {
Self::Http(error)
@@ -97,13 +103,21 @@ impl From<reqwest::Error> for BackendWorkspaceClientError {
pub async fn list_backend_workspaces(
target: &BackendWorkspaceCatalogTarget,
) -> Result<Vec<BackendWorkspace>, 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<Vec<BackendWorkspace>, BackendWorkspaceClientError> {
let response = client
.request(
Method::GET,
&format!("/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}"),
)?
.send()
.await?;
client.check_status(response.status())?;
Ok(response.json::<Vec<BackendWorkspace>>().await?)
}
@@ -111,42 +125,50 @@ pub async fn create_backend_workspace(
target: &BackendWorkspaceCatalogTarget,
request: &CreateBackendWorkspaceRequest,
) -> Result<CreateBackendWorkspaceResponse, BackendWorkspaceClientError> {
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::<CreateBackendWorkspaceResponse>().await?)
}
async fn require_success(
response: reqwest::Response,
) -> Result<reqwest::Response, BackendWorkspaceClientError> {
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() {
+6 -1
View File
@@ -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,
+13 -2
View File
@@ -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<String>, workspace_id: Option<impl Into<String>>) -> 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, BackendApiClientError> {
BackendApiClient::from_stored_token(&self.base_url)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
+77 -47
View File
@@ -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<String>,
workspace_id: impl Into<String>,
) -> Result<Self, BackendWorkspaceClientError> {
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<String>,
workspace_id: impl Into<String>,
access_token: &str,
) -> Result<Self, BackendWorkspaceClientError> {
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<R, BackendWorkspaceClientError> {
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<reqwest::blocking::RequestBuilder, BackendWorkspaceClientError> {
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<reqwest::blocking::Response, BackendWorkspaceClientError> {
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"));
}
+7 -56
View File
@@ -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<String, BackendTokenEntry>,
}
#[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::<BackendTokenFile>(&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<PathBuf> {
yoi_config_dir().map(|dir| dir.join("backend-tokens.json"))
}
fn yoi_config_dir() -> Option<PathBuf> {
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<plugin_cli::PluginCliCommand, ParseError> {
let Some((subcommand, rest)) = args.split_first() else {
return Err(ParseError(