Merge remote-tracking branch 'refs/remotes/origin/hare/develop' into develop

This commit is contained in:
2026-09-03 09:47:53 +09:00
41 changed files with 2218 additions and 379 deletions
+71
View File
@@ -192,6 +192,32 @@ impl BackendApiClient {
format!("Bearer {}", self.access_token.0)
}
pub async fn require_success(
&self,
response: reqwest::Response,
) -> Result<reqwest::Response, BackendApiClientError> {
let status = response.status();
match status {
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => {
self.check_status(status)?;
}
status if !status.is_success() => {
let detail = response
.bytes()
.await
.ok()
.and_then(|body| backend_error_detail(&body));
return Err(BackendApiClientError::BackendResponse {
origin: self.origin.clone(),
status: status.as_u16(),
detail,
});
}
_ => {}
}
Ok(response)
}
pub fn check_status(&self, status: StatusCode) -> Result<(), BackendApiClientError> {
match status {
StatusCode::UNAUTHORIZED => Err(BackendApiClientError::Unauthorized {
@@ -235,6 +261,18 @@ fn redirect_policy(origin: BackendOrigin) -> redirect::Policy {
})
}
#[derive(Deserialize)]
struct BackendErrorBody {
message: String,
}
fn backend_error_detail(body: &[u8]) -> Option<String> {
serde_json::from_slice::<BackendErrorBody>(body)
.ok()
.map(|body| body.message)
.filter(|message| !message.trim().is_empty())
}
#[derive(Debug)]
pub enum BackendApiClientError {
InvalidBackendOrigin(String),
@@ -266,6 +304,11 @@ pub enum BackendApiClientError {
origin: BackendOrigin,
status: u16,
},
BackendResponse {
origin: BackendOrigin,
status: u16,
detail: Option<String>,
},
Io {
path: PathBuf,
source: std::io::Error,
@@ -312,6 +355,17 @@ impl fmt::Display for BackendApiClientError {
Self::BackendStatus { origin, status } => {
write!(f, "Backend {origin} returned HTTP {status}")
}
Self::BackendResponse {
origin,
status,
detail,
} => {
write!(f, "Backend {origin} returned HTTP {status}")?;
if let Some(detail) = detail {
write!(f, ": {detail}")?;
}
Ok(())
}
Self::Io { path, source } => {
write!(f, "failed to access {}: {source}", path.display())
}
@@ -584,6 +638,23 @@ mod tests {
);
}
#[test]
fn backend_error_detail_preserves_public_server_message() {
let detail = backend_error_detail(
br#"{"error":"Bad Request","message":"working_directory_runtime_mismatch: Working directory is owned by a different Runtime","diagnostics":[{"code":"working_directory_runtime_mismatch"}]}"#,
);
let error = BackendApiClientError::BackendResponse {
origin: BackendOrigin::parse("http://127.0.0.1:8787").unwrap(),
status: 400,
detail,
};
assert_eq!(
error.to_string(),
"Backend http://127.0.0.1:8787 returned HTTP 400: working_directory_runtime_mismatch: Working directory is owned by a different Runtime"
);
}
#[test]
fn backend_origin_rejects_unsafe_authority_changes() {
for invalid in [
+1 -1
View File
@@ -387,7 +387,7 @@ pub async fn restore_backend_worker(
.json(&serde_json::json!({}))
.send()
.await?;
api.check_status(response.status())?;
let response = api.require_success(response).await?;
Ok(response.json::<BackendWorkerRestoreResponse>().await?)
}
+28 -14
View File
@@ -101,20 +101,24 @@ pub fn complete_current(
let utf8_byte_offset = utf16_to_utf8_offset(&source, utf16_offset)?;
let result = session_environment(snapshot.clone())
.complete_config(&entrypoint, &source, utf8_byte_offset, explicit)
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?
.map(|result| WasmCompletionResult {
from: result.from,
items: result
.items
.into_iter()
.map(|item| WasmCompletionItem {
label: item.label,
kind: format!("{:?}", item.kind).to_lowercase(),
detail: item.detail,
priority: item.priority,
})
.collect(),
});
.map_err(|error| JsValue::from_str(&format!("{error:?}")))?;
let result = result
.map(|result| {
Ok::<WasmCompletionResult, JsValue>(WasmCompletionResult {
from: utf8_to_utf16_offset(&source, result.from)?,
items: result
.items
.into_iter()
.map(|item| WasmCompletionItem {
label: item.label,
kind: format!("{:?}", item.kind).to_lowercase(),
detail: item.detail,
priority: item.priority,
})
.collect(),
})
})
.transpose()?;
encode(result)
})
}
@@ -177,6 +181,16 @@ fn utf16_to_utf8_offset(source: &str, utf16_offset: usize) -> Result<usize, JsVa
}
}
fn utf8_to_utf16_offset(source: &str, utf8_offset: usize) -> Result<usize, JsValue> {
if utf8_offset > source.len() {
return Err(JsValue::from_str("UTF-8 offset is outside the source"));
}
if !source.is_char_boundary(utf8_offset) {
return Err(JsValue::from_str("UTF-8 offset splits a character"));
}
Ok(source[..utf8_offset].encode_utf16().count())
}
fn decode<T: serde::de::DeserializeOwned>(value: JsValue) -> Result<T, JsValue> {
from_value(value).map_err(|error| JsValue::from_str(&error.to_string()))
}
+28
View File
@@ -1203,6 +1203,9 @@ impl SnapshotEnvironment {
{
let mut member_source = format!("{WORKSPACE_CONFIG_SCHEMA_GLOBAL}.");
member_source.push_str(&context.schema_path.join("."));
if !context.schema_path.is_empty() && context.from == utf8_byte_offset {
member_source.push('.');
}
let mut completion = LanguageService::new(self).complete(
entrypoint.as_str(),
&member_source,
@@ -1961,6 +1964,31 @@ mod tests {
.iter()
.any(|item| item.label == "default_profile")
);
let blank_nested_source = "{ profile = { } } as WorkspaceConfigSchema";
let blank_nested_cursor = blank_nested_source.find("{ }").unwrap() + 2;
let blank_nested = environment
.complete_config(
&path("main.dcdl"),
blank_nested_source,
blank_nested_cursor,
true,
)
.unwrap()
.unwrap();
assert_eq!(blank_nested.from, blank_nested_cursor);
assert!(
blank_nested
.items
.iter()
.any(|item| item.label == "default_profile")
);
assert!(
!blank_nested
.items
.iter()
.any(|item| item.label == "profile")
);
}
#[test]
+27 -47
View File
@@ -7,15 +7,15 @@ use client::{
list_backend_workers, restore_backend_worker,
};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::backend::CrosstermBackend;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
use crate::backend_workspace_picker::select_backend_workspace;
use crate::console;
use crate::inline_terminal::with_inline_terminal;
const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4;
@@ -127,31 +127,32 @@ fn pick_worker(
workers.truncate(MAX_ROWS);
let mut state = BackendWorkerPickerState::new(target, workers);
let mut terminal = make_inline_terminal()?;
loop {
terminal.draw(|frame| draw(frame, &state))?;
match poll_event()? {
None => continue,
Some(Action::Up) => state.previous(),
Some(Action::Down) => state.next(),
Some(Action::Submit) => {
close_viewport(&mut terminal)?;
return Ok(WorkerPickerResult::Selected(
state.selected_worker().clone(),
));
with_inline_terminal(
VIEWPORT_LINES,
|terminal| -> Result<_, Box<dyn std::error::Error>> {
loop {
terminal.draw(|frame| draw(frame, &state))?;
match poll_event()? {
None => continue,
Some(Action::Up) => state.previous(),
Some(Action::Down) => state.next(),
Some(Action::Submit) => {
return Ok(WorkerPickerResult::Selected(
state.selected_worker().clone(),
));
}
Some(Action::SwitchWorkspace) => {
return Ok(WorkerPickerResult::SwitchWorkspace);
}
Some(Action::Cancel) => {
return Err(Box::new(io::Error::other(
"Backend worker picker cancelled",
)));
}
}
}
Some(Action::SwitchWorkspace) => {
close_viewport(&mut terminal)?;
return Ok(WorkerPickerResult::SwitchWorkspace);
}
Some(Action::Cancel) => {
close_viewport(&mut terminal)?;
return Err(Box::new(io::Error::other(
"Backend worker picker cancelled",
)));
}
}
}
},
)
}
struct BackendWorkerPickerState {
@@ -184,27 +185,6 @@ impl BackendWorkerPickerState {
}
}
fn make_inline_terminal() -> io::Result<Terminal<CrosstermBackend<io::Stdout>>> {
let backend = CrosstermBackend::new(io::stdout());
Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(VIEWPORT_LINES),
},
)
}
fn close_viewport(terminal: &mut Terminal<CrosstermBackend<io::Stdout>>) -> io::Result<()> {
let area = terminal.get_frame().area();
let last_row = area.bottom().saturating_sub(1);
terminal.set_cursor_position((0, last_row))?;
use std::io::Write;
let mut out = io::stdout();
out.write_all(b"\r\n")?;
out.flush()?;
Ok(())
}
enum Action {
Up,
Down,
+109
View File
@@ -0,0 +1,109 @@
use std::io::{self, Stdout, Write};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::{TerminalOptions, Viewport};
pub(crate) type InlineTerminal = Terminal<CrosstermBackend<Stdout>>;
struct InlineTerminalGuard {
terminal: InlineTerminal,
closed: bool,
}
impl InlineTerminalGuard {
fn open(height: u16) -> io::Result<Self> {
let terminal = Terminal::with_options(
CrosstermBackend::new(io::stdout()),
TerminalOptions {
viewport: Viewport::Inline(height),
},
)?;
Ok(Self {
terminal,
closed: false,
})
}
fn close(&mut self) -> io::Result<()> {
if self.closed {
return Ok(());
}
self.closed = true;
let area = self.terminal.get_frame().area();
let last_row = area.bottom().saturating_sub(1);
let cursor_result = self.terminal.set_cursor_position((0, last_row));
let output_result = write_viewport_terminator(&mut io::stdout());
cursor_result?;
output_result
}
}
impl Drop for InlineTerminalGuard {
fn drop(&mut self) {
let _ = self.close();
}
}
pub(crate) fn with_inline_terminal<T, E>(
height: u16,
run: impl FnOnce(&mut InlineTerminal) -> Result<T, E>,
) -> Result<T, E>
where
E: From<io::Error>,
{
let mut guard = InlineTerminalGuard::open(height).map_err(E::from)?;
let result = run(&mut guard.terminal);
let close_result = guard.close();
match result {
Ok(value) => {
close_result.map_err(E::from)?;
Ok(value)
}
Err(error) => Err(error),
}
}
fn write_viewport_terminator(output: &mut impl Write) -> io::Result<()> {
output.write_all(b"\r\n")?;
output.flush()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn viewport_terminator_moves_following_output_to_a_fresh_line() {
let mut output = Vec::new();
write_viewport_terminator(&mut output).unwrap();
assert_eq!(output, b"\r\n");
}
#[test]
fn inline_viewport_construction_is_owned_by_this_module() {
fn assert_shared_owner(path: &std::path::Path) {
for entry in std::fs::read_dir(path).unwrap() {
let path = entry.unwrap().path();
if path.is_dir() {
assert_shared_owner(&path);
} else if path.extension().and_then(|value| value.to_str()) == Some("rs")
&& path.file_name().and_then(|value| value.to_str())
!= Some("inline_terminal.rs")
{
let source = std::fs::read_to_string(&path).unwrap();
assert!(
!source.contains("Viewport::Inline"),
"{} constructs an inline viewport outside its shared owner",
path.display()
);
}
}
}
assert_shared_owner(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"));
}
}
+5 -34
View File
@@ -1,17 +1,17 @@
use std::io::{self, Stdout, Write};
use std::process::ExitCode;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers};
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::backend::CrosstermBackend;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use ratatui::{Frame, Terminal, TerminalOptions, Viewport};
use secrets::{SecretStore, SecretValue};
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
#[derive(Debug, Clone, PartialEq, Eq)]
enum Mode {
Normal,
@@ -235,7 +235,6 @@ pub async fn launch() -> ExitCode {
}
type UiResult<T> = Result<T, Box<dyn std::error::Error>>;
type InlineTerminal = Terminal<CrosstermBackend<Stdout>>;
const MAX_ROWS: usize = 10;
const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 5;
@@ -270,37 +269,9 @@ impl Drop for RawModeGuard {
fn run(store: SecretStore) -> UiResult<()> {
enable_raw_mode()?;
let guard = RawModeGuard::new();
let mut terminal = make_inline_terminal()?;
let result = run_loop(&mut terminal, store);
let close_result = close_viewport(&mut terminal);
drop(terminal);
let result = with_inline_terminal(VIEWPORT_LINES, |terminal| run_loop(terminal, store));
guard.restore();
result?;
close_result?;
Ok(())
}
fn make_inline_terminal() -> io::Result<InlineTerminal> {
let backend = CrosstermBackend::new(io::stdout());
Terminal::with_options(
backend,
TerminalOptions {
viewport: Viewport::Inline(VIEWPORT_LINES),
},
)
}
/// Park the cursor at the very bottom of the inline viewport and emit one
/// newline before dropping the terminal. This matches the resume picker and
/// keeps the shell prompt (or a later inline viewport) from drawing over rows.
fn close_viewport(terminal: &mut InlineTerminal) -> io::Result<()> {
let area = terminal.get_frame().area();
let last_row = area.bottom().saturating_sub(1);
terminal.set_cursor_position((0, last_row))?;
let mut out = io::stdout();
out.write_all(b"\r\n")?;
out.flush()?;
Ok(())
result
}
fn run_loop(terminal: &mut InlineTerminal, store: SecretStore) -> UiResult<()> {
+1
View File
@@ -10,6 +10,7 @@ mod composer_keys;
mod console;
#[cfg(feature = "e2e-test")]
mod e2e_observer;
mod inline_terminal;
mod input;
pub mod keys;
mod markdown;
+31 -37
View File
@@ -3,15 +3,14 @@ use std::time::Duration;
use client::{StandaloneWorkerListIntent, StandaloneWorkerResumeIntent, Target};
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Layout};
use ratatui::prelude::{Color, Line, Modifier, Span, Style};
use ratatui::widgets::Paragraph;
use ratatui::{TerminalOptions, Viewport};
use standalone::{StandaloneListScope, StandaloneWorkerRecord, StandaloneWorkerStore};
use thiserror::Error;
use crate::inline_terminal::with_inline_terminal;
const LIMIT: usize = 100;
pub(crate) fn pick(
@@ -57,41 +56,36 @@ fn run_picker(
records: Vec<StandaloneWorkerRecord>,
) -> Result<Option<StandaloneWorkerRecord>, StandalonePickerError> {
let height = u16::try_from(records.len().saturating_add(3).min(20)).unwrap_or(20);
let mut terminal = Terminal::with_options(
CrosstermBackend::new(io::stdout()),
TerminalOptions {
viewport: Viewport::Inline(height),
},
)
.map_err(StandalonePickerError::Io)?;
let mut selected = 0usize;
loop {
terminal
.draw(|frame| draw(frame, &records, selected))
.map_err(StandalonePickerError::Io)?;
if !event::poll(Duration::from_millis(100)).map_err(StandalonePickerError::Io)? {
continue;
}
let TermEvent::Key(key) = event::read().map_err(StandalonePickerError::Io)? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Up | KeyCode::Char('k') if !ctrl => {
selected = selected.saturating_sub(1);
with_inline_terminal(height, |terminal| {
let mut selected = 0usize;
loop {
terminal
.draw(|frame| draw(frame, &records, selected))
.map_err(StandalonePickerError::Io)?;
if !event::poll(Duration::from_millis(100)).map_err(StandalonePickerError::Io)? {
continue;
}
KeyCode::Down | KeyCode::Char('j') if !ctrl => {
selected = (selected + 1).min(records.len() - 1);
let TermEvent::Key(key) = event::read().map_err(StandalonePickerError::Io)? else {
continue;
};
if key.kind == KeyEventKind::Release {
continue;
}
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Up | KeyCode::Char('k') if !ctrl => {
selected = selected.saturating_sub(1);
}
KeyCode::Down | KeyCode::Char('j') if !ctrl => {
selected = (selected + 1).min(records.len() - 1);
}
KeyCode::Enter => return Ok(Some(records[selected].clone())),
KeyCode::Esc => return Ok(None),
KeyCode::Char('c') if ctrl => return Ok(None),
_ => {}
}
KeyCode::Enter => return Ok(Some(records[selected].clone())),
KeyCode::Esc => return Ok(None),
KeyCode::Char('c') if ctrl => return Ok(None),
_ => {}
}
}
})
}
fn draw(frame: &mut ratatui::Frame<'_>, records: &[StandaloneWorkerRecord], selected: usize) {
@@ -145,11 +139,11 @@ pub(crate) enum StandalonePickerError {
#[error("standalone Worker state is unavailable: {0}")]
StateStore(#[source] standalone::StandaloneStoreError),
#[error(
"no standalone Workers found for this cwd; use `yoi --local --resume --all` to include all cwd identities"
"no standalone Workers found for this cwd; use `yoi --local resume --all` to include all cwd identities"
)]
NoWorkers { include_all: bool },
#[error("standalone Worker picker I/O failed: {0}")]
Io(#[source] io::Error),
Io(#[from] io::Error),
}
#[cfg(test)]
+10 -16
View File
@@ -1,17 +1,17 @@
use std::io::{self, Stdout};
use std::io;
use std::path::Path;
use std::time::Duration;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use manifest::ProfileDiscovery;
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use thiserror::Error;
use crate::inline_terminal::{InlineTerminal, with_inline_terminal};
const VIEWPORT_HEIGHT: u16 = 6;
const FALLBACK_WORKER_NAME: &str = "worker";
@@ -182,15 +182,16 @@ pub(crate) fn select(
return Err(StandaloneSpawnError::NoProfiles);
}
let terminal = open_inline_terminal()?;
run_picker(
terminal,
SpawnForm::new(worker_name, default_worker_name, choices),
)
with_inline_terminal(VIEWPORT_HEIGHT, |terminal| {
run_picker(
terminal,
SpawnForm::new(worker_name, default_worker_name, choices),
)
})
}
fn run_picker(
mut terminal: Terminal<CrosstermBackend<Stdout>>,
terminal: &mut InlineTerminal,
mut form: SpawnForm,
) -> Result<Option<StandaloneSpawnSelection>, StandaloneSpawnError> {
loop {
@@ -222,13 +223,6 @@ fn run_picker(
}
}
fn open_inline_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
let options = ratatui::TerminalOptions {
viewport: ratatui::Viewport::Inline(VIEWPORT_HEIGHT),
};
Terminal::with_options(CrosstermBackend::new(io::stdout()), options)
}
fn profile_choices(registry: &manifest::ProfileRegistry) -> Vec<ProfileChoice> {
registry
.entries()
+72 -10
View File
@@ -134,6 +134,8 @@ pub trait EmbeddedWorkerMutationDispatcher: Send + Sync {
enum RuntimeWorkerMutationTransport {
Remote {
base_url: String,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
},
Embedded {
dispatcher: Arc<dyn EmbeddedWorkerMutationDispatcher>,
@@ -157,10 +159,12 @@ impl RuntimeWorkerMutationForwarder {
) -> Self {
Self {
authority: RuntimeWorkerMutationSourceAuthority::remote(identity),
scope,
scope: scope.clone(),
source_worker_id: source_worker_id.into(),
transport: RuntimeWorkerMutationTransport::Remote {
base_url: base_url.into().trim_end_matches('/').to_string(),
request_source_signer: RuntimeRequestSourceSigner::from_identity(identity),
request_source_audience: scope.server_id,
},
}
}
@@ -197,11 +201,18 @@ impl RuntimeWorkerMutationForwarder {
)?;
match (&self.transport, proof) {
(
RuntimeWorkerMutationTransport::Remote { base_url },
RuntimeWorkerMutationTransport::Remote {
base_url,
request_source_signer,
request_source_audience,
},
RuntimeOwnedWorkerMutationProof::Remote(token),
) => execute_remote_worker_remove_http(RemoteWorkerRemoveHttpRequest {
base_url: base_url.clone(),
workspace_id: self.scope.workspace_id.clone(),
source_worker_id: self.source_worker_id.clone(),
request_source_signer: request_source_signer.clone(),
request_source_audience: request_source_audience.clone(),
token,
target_runtime_id: target_runtime_id.to_string(),
target_worker_id: target_worker_id.to_string(),
@@ -224,6 +235,9 @@ impl RuntimeWorkerMutationForwarder {
struct RemoteWorkerRemoveHttpRequest {
base_url: String,
workspace_id: String,
source_worker_id: String,
request_source_signer: RuntimeRequestSourceSigner,
request_source_audience: String,
token: String,
target_runtime_id: String,
target_worker_id: String,
@@ -256,23 +270,35 @@ fn execute_remote_worker_remove_http(
fn execute_remote_worker_remove_http_blocking(
request: RemoteWorkerRemoveHttpRequest,
) -> Result<WorkspaceResponse, RuntimeWorkerMutationForwardError> {
let url = format!(
"{}/api/w/{}/workers/remove",
request.base_url, request.workspace_id
);
let body = serde_json::json!({
let path = format!("/api/w/{}/workers/remove", request.workspace_id);
let url = format!("{}{}", request.base_url, path);
let body = serde_json::to_string(&serde_json::json!({
"target_runtime_id": request.target_runtime_id,
"target_worker_id": request.target_worker_id,
"reason": request.reason,
});
}))
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
let request_source_proof = request.request_source_signer.issue(
&request.request_source_audience,
&request.workspace_id,
Some(&request.source_worker_id),
WORKSPACE_REQUEST_PERMISSION,
"POST",
&path,
body.as_bytes(),
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX),
30,
)?;
let client = reqwest::blocking::Client::new();
let response = client
.post(url)
.header(RUNTIME_REQUEST_SOURCE_PROOF_HEADER, request_source_proof)
.header(
crate::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
request.token,
)
.json(&body)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body)
.send()
.map_err(|error| RuntimeWorkerMutationForwardError::Transport(error.to_string()))?;
let status = response.status().as_u16();
@@ -697,7 +723,8 @@ mod tests {
use super::*;
use crate::auth::{
WorkerMutationSourceExpectation, decode_runtime_request_source_claims,
decode_worker_mutation_source_claims, verify_worker_mutation_source_proof,
decode_worker_mutation_source_claims, request_body_digest,
verify_worker_mutation_source_proof,
};
#[test]
@@ -1119,6 +1146,41 @@ mod tests {
assert!(request.contains("\"target_worker_id\":\"worker-target\""));
assert!(!request.contains("expected_worker_revision"));
assert!(request.contains("\"reason\":\"retire obsolete Worker\""));
let request_source_token = request
.lines()
.find_map(|line| {
line.split_once(':').and_then(|(name, value)| {
name.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
.then(|| value.trim())
})
})
.expect("runtime request source proof header");
let request_source_claims =
decode_runtime_request_source_claims(request_source_token).unwrap();
assert_eq!(request_source_claims.iss, "runtime-a");
assert_eq!(request_source_claims.aud, "server-a");
assert_eq!(request_source_claims.workspace_id, "workspace-a");
assert_eq!(
request_source_claims.worker_id.as_deref(),
Some("worker-source")
);
assert_eq!(
request_source_claims.permission,
WORKSPACE_REQUEST_PERMISSION
);
assert_eq!(request_source_claims.method, "POST");
assert_eq!(
request_source_claims.path,
"/api/w/workspace-a/workers/remove"
);
let request_body = request
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.expect("WorkerRemove request body");
assert_eq!(
request_source_claims.body_digest,
request_body_digest(request_body.as_bytes())
);
let token = request
.lines()
.find_map(|line| {
+154 -8
View File
@@ -1990,16 +1990,18 @@ impl WorkspaceApi {
.list_worker_workdir_links(&self.config.workspace_id, worker)?
.into_iter()
.find(|link| link.unlinked_at.is_none())
&& let Some(access) = repository_access_request_for_workdir(
{
let workdir_runtime_id = registered_workdir_runtime_id(self, &link.workdir_id)?;
if let Some(access) = repository_access_request_for_workdir(
self,
&worker.runtime_id,
&workdir_runtime_id,
&link.workdir_id,
&format!("worker-restore:{}", WorkerId::now_v7()),
)?
{
self.runtime
.authorize_working_directory_repository_access(&worker.runtime_id, access)
.map_err(RuntimeRegistryError::into_error)?;
)? {
self.runtime
.authorize_working_directory_repository_access(&workdir_runtime_id, access)
.map_err(RuntimeRegistryError::into_error)?;
}
}
let binding = self
.runtime
@@ -22720,6 +22722,119 @@ mod tests {
assert!(!route_body.contains("source"));
assert!(!route_body.contains("proof"));
let outer_path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/remove");
let outer_request_body = r#"{"target_runtime_id":"runtime-target","target_worker_id":"target-worker","reason":"retire target Worker"}"#;
let outer_mutation_token = signer
.issue_worker_remove(
"server-main",
&api.config.workspace_id,
"7",
"runtime-target",
"target-worker",
60,
)
.unwrap();
let outer_request_token =
worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity)
.issue(
"server-main",
&api.config.workspace_id,
Some("7"),
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
"POST",
&outer_path,
outer_request_body.as_bytes(),
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.unwrap();
let outer_response = build_router(api.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(&outer_path)
.header(CONTENT_TYPE, "application/json")
.header(
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
outer_request_token,
)
.header(
worker_runtime::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
outer_mutation_token,
)
.body(Body::from(outer_request_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(outer_response.status(), StatusCode::NOT_FOUND);
let outer_response_body = axum::body::to_bytes(outer_response.into_body(), usize::MAX)
.await
.unwrap();
assert!(
String::from_utf8(outer_response_body.to_vec())
.unwrap()
.contains("unknown_worker")
);
let missing_outer_mutation_token = signer
.issue_worker_remove(
"server-main",
&api.config.workspace_id,
"7",
"runtime-target",
"target-worker",
60,
)
.unwrap();
let missing_outer_response = build_router(api.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(&outer_path)
.header(CONTENT_TYPE, "application/json")
.header(
worker_runtime::auth::WORKER_MUTATION_SOURCE_PROOF_HEADER,
missing_outer_mutation_token,
)
.body(Body::from(outer_request_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing_outer_response.status(), StatusCode::UNAUTHORIZED);
let missing_mutation_request_token =
worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity)
.issue(
"server-main",
&api.config.workspace_id,
Some("7"),
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
"POST",
&outer_path,
outer_request_body.as_bytes(),
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap_or(i64::MAX),
30,
)
.unwrap();
let missing_mutation_response = build_router(api.clone())
.oneshot(
Request::builder()
.method("POST")
.uri(&outer_path)
.header(CONTENT_TYPE, "application/json")
.header(
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
missing_mutation_request_token,
)
.body(Body::from(outer_request_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(missing_mutation_response.status(), StatusCode::UNAUTHORIZED);
let mut revoked = trust;
revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string());
let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap();
@@ -26480,7 +26595,7 @@ mod tests {
)
.await
.unwrap();
let app = build_inner_router(api);
let app = build_inner_router(api.clone());
let runtimes = get_json(app.clone(), "/api/runtimes").await;
let embedded_summary = runtimes["items"]
@@ -26535,6 +26650,37 @@ mod tests {
"embedded_worker_runtime"
);
let workdir_id = "external-workdir";
api.store
.upsert_workdir_registry(&WorkdirRegistryRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
workdir_id: workdir_id.to_string(),
runtime_id: "external-workdir-runtime".to_string(),
repository_id: "main".to_string(),
creation_selector: None,
creation_ref: None,
creation_tree: None,
current_selector: None,
current_ref: None,
current_tree: None,
observed_at_epoch_seconds: None,
materialization_status: "present".to_string(),
cleanliness: "clean".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.unwrap();
api.store
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
workspace_id: TEST_WORKSPACE_ID.to_string(),
worker: RuntimeWorkerRef::new("embedded-worker-runtime", &worker_id),
workdir_id: workdir_id.to_string(),
role: "attachment".to_string(),
linked_at: "2".to_string(),
unlinked_at: None,
})
.unwrap();
let worker = get_json(
app.clone(),
&format!("/api/runtimes/embedded-worker-runtime/workers/{worker_id}"),
+15 -76
View File
@@ -571,20 +571,10 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
let mut socket_override = None;
let mut runtime_id = None;
let mut worker_id = None;
let mut standalone_resume = false;
let mut standalone_all = false;
let mut i = 0;
while i < args.len() {
let arg = &args[i];
match arg.as_str() {
"--resume" => {
standalone_resume = true;
i += 1;
}
"--all" => {
standalone_all = true;
i += 1;
}
"--worker" => {
let value = args
.get(i + 1)
@@ -766,29 +756,6 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
&workspace_root,
)?;
if standalone_all && !standalone_resume {
return Err(ParseError("--all requires --resume".to_string()));
}
if standalone_resume {
if target.kind() != TargetKind::Standalone {
return Err(ParseError(
"--resume is a Standalone option and requires --local".to_string(),
));
}
if worker_name.is_some()
|| profile.is_some()
|| session.is_some()
|| socket_override.is_some()
|| runtime_id.is_some()
|| worker_id.is_some()
{
return Err(ParseError(
"--local --resume cannot be combined with Worker, profile, session, socket, or Runtime selectors"
.to_string(),
));
}
}
if target.kind() == TargetKind::Standalone {
if runtime_id.is_some() || worker_id.is_some() {
return Err(ParseError(
@@ -798,7 +765,7 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
}
if session.is_some() {
return Err(ParseError(
"--local does not accept legacy --session; use --local --resume for Standalone Worker restore"
"--local does not accept legacy --session; use `yoi --local resume` for Standalone Worker restore"
.to_string(),
));
}
@@ -834,16 +801,12 @@ fn parse_console_options<R: CliConnectionResolver + ?Sized>(
if target.kind() == TargetKind::Standalone && (session.is_some() || socket_override.is_some()) {
return Err(ParseError(
"Standalone does not accept legacy Worker session or socket selectors; use --resume for the standalone Worker store"
"Standalone does not accept legacy Worker session or socket selectors; use `yoi --local resume` for the standalone Worker store"
.to_string(),
));
}
let mode = if standalone_resume {
LaunchMode::StandaloneResume {
include_all: standalone_all,
}
} else if target.kind() == TargetKind::Standalone {
let mode = if target.kind() == TargetKind::Standalone {
LaunchMode::Spawn {
worker_name,
profile,
@@ -951,7 +914,7 @@ fn parse_workers_args<R: CliConnectionResolver + ?Sized>(
)?;
if target.kind() != TargetKind::Backend {
return Err(ParseError(
"yoi workers requires a Backend connection target; use yoi --local --resume for Standalone Workers"
"yoi workers requires a Backend connection target; use yoi --local resume for Standalone Workers"
.to_string(),
));
}
@@ -1741,7 +1704,6 @@ const TOP_LEVEL_HELP: &str = r#"yoi
Usage:
yoi [TARGET]
yoi --local --resume [--all]
yoi [TARGET] workers [-r|--stopped] [--runtime-id <ID>]
yoi [TARGET] resume [--all] [--runtime-id <ID>]
yoi --backend <URL> [--workspace-id <ID>] panel
@@ -1750,8 +1712,6 @@ Usage:
Target selection:
--local Use the client-owned one-process Standalone host
--resume With --local, restore from the Standalone Worker store
--all With Standalone restore, include Workers from every cwd identity
--backend <URL> Use a Workspace Backend explicitly
--workspace-id <ID> Scope Backend routes to a Workspace id
@@ -1801,7 +1761,7 @@ Usage:
Authority:
Lists Workers from the selected Backend Workspace. Standalone Workers are restored with
`yoi --local --resume` and are not part of the Workspace Worker catalog.
`yoi --local resume` and are not part of the Workspace Worker catalog.
Options:
--backend <URL> Use this Workspace Backend
@@ -2212,35 +2172,14 @@ backend = "shared"
}
#[test]
fn parser_local_resume_uses_standalone_picker_scope() {
let mode = parse_args_from(["--local", "--resume"]).unwrap();
let Mode::Tui { target, mode, .. } = mode else {
panic!("expected TUI mode")
};
assert_eq!(target.kind(), TargetKind::Standalone);
assert!(matches!(
mode,
LaunchMode::StandaloneResume { include_all: false }
));
let intent = target.standalone_worker_list(false).unwrap();
assert!(intent.state_dir.ends_with("client/standalone/workers"));
assert!(!intent.include_all);
let mode = parse_args_from(["--local", "--resume", "--all"]).unwrap();
let Mode::Tui { mode, .. } = mode else {
panic!("expected TUI mode")
};
assert!(matches!(
mode,
LaunchMode::StandaloneResume { include_all: true }
));
assert_eq!(
parse_args_from(["--local", "--all"])
.unwrap_err()
.to_string(),
"--all requires --resume"
);
fn parser_rejects_removed_top_level_resume_flags() {
for (args, expected) in [
(vec!["--resume"], "unknown argument: --resume"),
(vec!["--local", "--resume"], "unknown argument: --resume"),
(vec!["--all"], "unknown argument: --all"),
] {
assert_eq!(parse_args_from(args).unwrap_err().to_string(), expected);
}
}
#[test]
@@ -2264,7 +2203,7 @@ backend = "shared"
let err = parse_args_slice_with_connection_resolver(&session_args, &resolver).unwrap_err();
assert_eq!(
err.0,
"--local does not accept legacy --session; use --local --resume for Standalone Worker restore"
"--local does not accept legacy --session; use `yoi --local resume` for Standalone Worker restore"
);
let socket_args = [
@@ -2897,7 +2836,7 @@ backend = "shared"
other => panic!("expected WorkersHelp mode, got {other:?}"),
}
assert!(WORKERS_HELP.contains("selected Backend Workspace"));
assert!(WORKERS_HELP.contains("--local --resume"));
assert!(WORKERS_HELP.contains("--local resume"));
assert!(!WORKERS_HELP.contains("[--local|--backend"));
assert!(!WORKERS_HELP.contains("local Worker records"));
}