chore: merge develop into hare/develop
This commit is contained in:
Generated
+1
@@ -6181,6 +6181,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"fs-operation",
|
||||
"libc",
|
||||
"manifest",
|
||||
"reqwest",
|
||||
"schemars",
|
||||
|
||||
+1356
-70
File diff suppressed because it is too large
Load Diff
@@ -336,6 +336,14 @@ pub struct ToolResultInfo<'a, A = ()> {
|
||||
/// flows, permission checks, and other trusted host adaptation.
|
||||
#[async_trait]
|
||||
pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
||||
/// Whether side effects may start before [`Self::on_assistant_turn_end`].
|
||||
///
|
||||
/// The conservative default preserves the historical whole-response gate.
|
||||
/// Interceptors that do not use turn-end policy may opt in explicitly.
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Called after receiving user input, before adding it to Engine history.
|
||||
async fn on_prompt_submit(
|
||||
&self,
|
||||
@@ -420,4 +428,8 @@ pub trait Interceptor<A: Send + Sync = ()>: Send + Sync {
|
||||
pub(crate) struct DefaultInterceptor;
|
||||
|
||||
#[async_trait]
|
||||
impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {}
|
||||
impl<A: Send + Sync> Interceptor<A> for DefaultInterceptor {
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub use agen_macros::{description, tool, tool_registry};
|
||||
pub use callback::{TextBlockScope, ThinkingBlockScope, ToolUseBlockScope};
|
||||
pub use engine::{
|
||||
Engine, EngineConfig, EngineError, EngineResult, EngineRunExit, EngineRunOutput,
|
||||
LlmRetryNotice, RunInterruptionReason, ToolRegistryError,
|
||||
LlmRetryNotice, RunInterruptionReason, ToolCallDispatchMode, ToolRegistryError,
|
||||
};
|
||||
pub use handler::ToolUseBlockStart;
|
||||
pub use history::{History, HistoryEntry};
|
||||
|
||||
@@ -38,6 +38,16 @@ impl std::fmt::Display for ConfigWarning {
|
||||
|
||||
pub type ResponseStream = Pin<Box<dyn Stream<Item = Result<Event, ClientError>> + Send>>;
|
||||
|
||||
/// Whether normalized `BlockStop(ToolUse)` events prove one call is complete.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum ToolCallCompletionSupport {
|
||||
/// Individual tool-call completion events are provider-authoritative.
|
||||
PerBlock,
|
||||
/// Tool calls are complete only once the response stream itself completes.
|
||||
#[default]
|
||||
ResponseComplete,
|
||||
}
|
||||
|
||||
/// LLMクライアントのtrait
|
||||
///
|
||||
/// 各プロバイダはこのtraitを実装し、統一されたインターフェースを提供する。
|
||||
@@ -59,6 +69,11 @@ pub trait LlmClient: Send + Sync {
|
||||
/// compaction) without access to the original construction parameters.
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient>;
|
||||
|
||||
/// Describe the strongest safe tool-call completion boundary.
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
ToolCallCompletionSupport::ResponseComplete
|
||||
}
|
||||
|
||||
/// 設定をバリデーションし、未サポートの設定があれば警告を返す
|
||||
///
|
||||
/// # Arguments
|
||||
@@ -92,6 +107,10 @@ impl LlmClient for Box<dyn LlmClient> {
|
||||
(**self).clone_boxed()
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
(**self).tool_call_completion_support()
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
(**self).validate_config(config)
|
||||
}
|
||||
|
||||
@@ -263,6 +263,7 @@ impl AnthropicScheme {
|
||||
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
match item {
|
||||
Item::AssistantResponseBoundary { .. } => {}
|
||||
Item::Message { role, content, .. } => {
|
||||
let parts: Vec<AnthropicContentPart> = content
|
||||
.iter()
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
client::ToolCallCompletionSupport,
|
||||
event::{BlockType, Event, ReasoningBlockData},
|
||||
scheme::Scheme,
|
||||
types::Request,
|
||||
@@ -104,4 +105,8 @@ impl Scheme for AnthropicScheme {
|
||||
fn default_capability(&self) -> ModelCapability {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
ToolCallCompletionSupport::PerBlock
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,6 +242,7 @@ impl GeminiScheme {
|
||||
|
||||
for item in items {
|
||||
match item {
|
||||
Item::AssistantResponseBoundary { .. } => {}
|
||||
Item::Message { role, content, .. } => {
|
||||
// Flush pending parts
|
||||
self.flush_pending_parts(
|
||||
|
||||
@@ -16,7 +16,7 @@ use serde_json::Value;
|
||||
|
||||
use super::auth::AuthRequirement;
|
||||
use super::capability::ModelCapability;
|
||||
use super::client::ConfigWarning;
|
||||
use super::client::{ConfigWarning, ToolCallCompletionSupport};
|
||||
use super::error::ClientError;
|
||||
use super::event::Event;
|
||||
use super::types::{Request, RequestConfig};
|
||||
@@ -81,6 +81,11 @@ pub trait Scheme: Clone + Send + Sync + 'static {
|
||||
/// 高レベルの client 構築層の責務で、scheme はここには関与しない。
|
||||
fn default_capability(&self) -> ModelCapability;
|
||||
|
||||
/// Strongest safe completion boundary for streamed tool calls.
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
ToolCallCompletionSupport::ResponseComplete
|
||||
}
|
||||
|
||||
/// scheme 側でサポートしていない `RequestConfig` フィールドを
|
||||
/// 警告として返す(例: OpenAI Chat は `top_k` 非対応)。
|
||||
/// デフォルトは空 Vec。
|
||||
@@ -89,3 +94,29 @@ pub trait Scheme: Clone + Send + Sync + 'static {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod completion_support_tests {
|
||||
use super::*;
|
||||
use crate::llm_client::ToolCallCompletionSupport;
|
||||
|
||||
#[test]
|
||||
fn only_schemes_with_authoritative_per_call_done_events_enable_early_dispatch() {
|
||||
assert_eq!(
|
||||
anthropic::AnthropicScheme::new().tool_call_completion_support(),
|
||||
ToolCallCompletionSupport::PerBlock
|
||||
);
|
||||
assert_eq!(
|
||||
openai_responses::OpenAIResponsesScheme::new().tool_call_completion_support(),
|
||||
ToolCallCompletionSupport::PerBlock
|
||||
);
|
||||
assert_eq!(
|
||||
openai_chat::OpenAIScheme::new().tool_call_completion_support(),
|
||||
ToolCallCompletionSupport::ResponseComplete
|
||||
);
|
||||
assert_eq!(
|
||||
gemini::GeminiScheme::new().tool_call_completion_support(),
|
||||
ToolCallCompletionSupport::ResponseComplete
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ impl OpenAIScheme {
|
||||
);
|
||||
}
|
||||
match item {
|
||||
Item::AssistantResponseBoundary { .. } => {}
|
||||
Item::Message { role, content, .. } => {
|
||||
// Flush pending tool calls
|
||||
self.flush_pending_assistant(
|
||||
|
||||
@@ -28,6 +28,43 @@ pub struct OpenAIResponsesState {
|
||||
/// 蓄積、`output_item.done`(Reasoning) で既存 reasoning_text block または
|
||||
/// metadata-only Thinking block に reasoning persistence material を載せる。
|
||||
pending_reasoning: HashMap<usize, PendingReasoning>,
|
||||
/// Tool input deltas and authoritative final values, keyed by output index.
|
||||
pending_tool_inputs: HashMap<usize, PendingToolInput>,
|
||||
}
|
||||
|
||||
/// A tool input stream accumulated until an authoritative done payload arrives.
|
||||
#[derive(Debug)]
|
||||
struct PendingToolInput {
|
||||
kind: ToolInputKind,
|
||||
streamed: String,
|
||||
saw_delta: bool,
|
||||
final_value: Option<String>,
|
||||
}
|
||||
|
||||
impl PendingToolInput {
|
||||
fn new(kind: ToolInputKind) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
streamed: String::new(),
|
||||
saw_delta: false,
|
||||
final_value: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ToolInputKind {
|
||||
FunctionCall,
|
||||
CustomToolCall,
|
||||
}
|
||||
|
||||
impl ToolInputKind {
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::FunctionCall => "function-call arguments",
|
||||
Self::CustomToolCall => "custom-tool input",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 1 つの reasoning output_item の蓄積バッファ。
|
||||
@@ -173,6 +210,8 @@ enum OutputItem {
|
||||
id: Option<String>,
|
||||
call_id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
arguments: Option<String>,
|
||||
},
|
||||
CustomToolCall {
|
||||
#[allow(dead_code)]
|
||||
@@ -180,6 +219,8 @@ enum OutputItem {
|
||||
id: Option<String>,
|
||||
call_id: String,
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
input: Option<String>,
|
||||
},
|
||||
#[serde(other)]
|
||||
Other,
|
||||
@@ -260,12 +301,24 @@ struct FunctionCallArgumentsDelta {
|
||||
delta: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FunctionCallArgumentsDone {
|
||||
output_index: usize,
|
||||
arguments: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CustomToolCallInputDelta {
|
||||
output_index: usize,
|
||||
delta: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CustomToolCallInputDone {
|
||||
output_index: usize,
|
||||
input: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ResponseCompleted {
|
||||
response: CompletedResponse,
|
||||
@@ -398,16 +451,20 @@ pub(crate) fn parse_sse(
|
||||
"response.output_item.added" => {
|
||||
let ev: OutputItemAdded = from_json(data)?;
|
||||
match ev.item {
|
||||
OutputItem::FunctionCall { call_id, name, .. }
|
||||
| OutputItem::CustomToolCall { call_id, name, .. } => {
|
||||
let info =
|
||||
state.allocate(SlotKey::OutputItem(ev.output_index), BlockType::ToolUse);
|
||||
Ok(vec![Event::BlockStart(BlockStart {
|
||||
index: info.flat_index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse { id: call_id, name },
|
||||
})])
|
||||
}
|
||||
OutputItem::FunctionCall { call_id, name, .. } => start_tool_output_item(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::FunctionCall,
|
||||
call_id,
|
||||
name,
|
||||
),
|
||||
OutputItem::CustomToolCall { call_id, name, .. } => start_tool_output_item(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::CustomToolCall,
|
||||
call_id,
|
||||
name,
|
||||
),
|
||||
OutputItem::Reasoning { id, .. } => {
|
||||
// wrapper を確保。中身の content_part / summary_part は
|
||||
// 別 SlotKey で扱われ続ける(Streaming 表示は維持)。
|
||||
@@ -431,19 +488,19 @@ pub(crate) fn parse_sse(
|
||||
id,
|
||||
encrypted_content,
|
||||
..
|
||||
} = ev.item
|
||||
} = &ev.item
|
||||
{
|
||||
let mut pending = state
|
||||
.pending_reasoning
|
||||
.remove(&ev.output_index)
|
||||
.unwrap_or_default();
|
||||
if pending.id.is_none() {
|
||||
pending.id = id;
|
||||
pending.id = id.clone();
|
||||
}
|
||||
|
||||
let mut stop_blocks = std::mem::take(&mut pending.deferred_thinking_stops);
|
||||
stop_blocks.extend(state.take_active_reasoning_slots(ev.output_index));
|
||||
let reasoning = pending.into_reasoning_data(encrypted_content);
|
||||
let reasoning = pending.into_reasoning_data(encrypted_content.clone());
|
||||
|
||||
if stop_blocks.is_empty() {
|
||||
let info =
|
||||
@@ -478,6 +535,34 @@ pub(crate) fn parse_sse(
|
||||
})
|
||||
.collect());
|
||||
}
|
||||
match ev.item {
|
||||
OutputItem::FunctionCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => finish_tool_output_item(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::FunctionCall,
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
),
|
||||
OutputItem::CustomToolCall {
|
||||
call_id,
|
||||
name,
|
||||
input,
|
||||
..
|
||||
} => finish_tool_output_item(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::CustomToolCall,
|
||||
call_id,
|
||||
name,
|
||||
input,
|
||||
),
|
||||
_ => {
|
||||
if let Some(info) = state.slots.remove(&SlotKey::OutputItem(ev.output_index)) {
|
||||
Ok(vec![Event::BlockStop(BlockStop {
|
||||
index: info.flat_index,
|
||||
@@ -489,6 +574,8 @@ pub(crate) fn parse_sse(
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"response.content_part.added" => {
|
||||
let ev: ContentPartAdded = from_json(data)?;
|
||||
@@ -619,30 +706,50 @@ pub(crate) fn parse_sse(
|
||||
|
||||
"response.function_call_arguments.delta" => {
|
||||
let ev: FunctionCallArgumentsDelta = from_json(data)?;
|
||||
Ok(ensure_and_delta(
|
||||
record_tool_input_delta(
|
||||
state,
|
||||
SlotKey::OutputItem(ev.output_index),
|
||||
BlockType::ToolUse,
|
||||
ev.output_index,
|
||||
ToolInputKind::FunctionCall,
|
||||
ev.delta,
|
||||
)
|
||||
}
|
||||
|
||||
"response.function_call_arguments.done" => {
|
||||
let ev: FunctionCallArgumentsDone = from_json(data)?;
|
||||
reconcile_tool_input(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::FunctionCall,
|
||||
ev.arguments,
|
||||
BlockMetadata::ToolUse {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
},
|
||||
DeltaContent::InputJson(ev.delta),
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
"response.custom_tool_call_input.delta" => {
|
||||
let ev: CustomToolCallInputDelta = from_json(data)?;
|
||||
Ok(ensure_and_delta(
|
||||
record_tool_input_delta(
|
||||
state,
|
||||
SlotKey::OutputItem(ev.output_index),
|
||||
BlockType::ToolUse,
|
||||
ev.output_index,
|
||||
ToolInputKind::CustomToolCall,
|
||||
ev.delta,
|
||||
)
|
||||
}
|
||||
|
||||
"response.custom_tool_call_input.done" => {
|
||||
let ev: CustomToolCallInputDone = from_json(data)?;
|
||||
reconcile_tool_input(
|
||||
state,
|
||||
ev.output_index,
|
||||
ToolInputKind::CustomToolCall,
|
||||
ev.input,
|
||||
BlockMetadata::ToolUse {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
},
|
||||
DeltaContent::InputJson(ev.delta),
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
"error" => {
|
||||
@@ -666,6 +773,181 @@ pub(crate) fn parse_sse(
|
||||
}
|
||||
}
|
||||
|
||||
fn start_tool_output_item(
|
||||
state: &mut OpenAIResponsesState,
|
||||
output_index: usize,
|
||||
kind: ToolInputKind,
|
||||
call_id: String,
|
||||
name: String,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
ensure_tool_input_kind(state, output_index, kind)?;
|
||||
let (info, just_created) =
|
||||
state.get_or_allocate(SlotKey::OutputItem(output_index), BlockType::ToolUse);
|
||||
if just_created {
|
||||
Ok(vec![Event::BlockStart(BlockStart {
|
||||
index: info.flat_index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata: BlockMetadata::ToolUse { id: call_id, name },
|
||||
})])
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn record_tool_input_delta(
|
||||
state: &mut OpenAIResponsesState,
|
||||
output_index: usize,
|
||||
kind: ToolInputKind,
|
||||
delta: String,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
let pending = ensure_tool_input_kind(state, output_index, kind)?;
|
||||
if pending.final_value.is_some() {
|
||||
return Err(tool_input_error(
|
||||
"tool_input_delta_after_done",
|
||||
format!(
|
||||
"OpenAI Responses {} delta arrived after its final value at output index {output_index}",
|
||||
kind.label()
|
||||
),
|
||||
));
|
||||
}
|
||||
pending.saw_delta = true;
|
||||
pending.streamed.push_str(&delta);
|
||||
|
||||
Ok(ensure_and_delta(
|
||||
state,
|
||||
SlotKey::OutputItem(output_index),
|
||||
BlockType::ToolUse,
|
||||
BlockMetadata::ToolUse {
|
||||
id: String::new(),
|
||||
name: String::new(),
|
||||
},
|
||||
DeltaContent::InputJson(delta),
|
||||
))
|
||||
}
|
||||
|
||||
fn reconcile_tool_input(
|
||||
state: &mut OpenAIResponsesState,
|
||||
output_index: usize,
|
||||
kind: ToolInputKind,
|
||||
final_value: String,
|
||||
metadata: BlockMetadata,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
let pending = ensure_tool_input_kind(state, output_index, kind)?;
|
||||
if pending
|
||||
.final_value
|
||||
.as_ref()
|
||||
.is_some_and(|existing| existing != &final_value)
|
||||
{
|
||||
return Err(tool_input_error(
|
||||
"tool_input_final_mismatch",
|
||||
format!(
|
||||
"OpenAI Responses authoritative {} values disagree at output index {output_index}",
|
||||
kind.label()
|
||||
),
|
||||
));
|
||||
}
|
||||
if pending.saw_delta && pending.streamed != final_value {
|
||||
return Err(tool_input_error(
|
||||
"tool_input_delta_mismatch",
|
||||
format!(
|
||||
"OpenAI Responses streamed {} does not match its authoritative final value at output index {output_index}",
|
||||
kind.label()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let should_emit = !pending.saw_delta;
|
||||
if should_emit {
|
||||
pending.saw_delta = true;
|
||||
pending.streamed.clone_from(&final_value);
|
||||
}
|
||||
pending.final_value = Some(final_value.clone());
|
||||
|
||||
if should_emit {
|
||||
Ok(ensure_and_delta(
|
||||
state,
|
||||
SlotKey::OutputItem(output_index),
|
||||
BlockType::ToolUse,
|
||||
metadata,
|
||||
DeltaContent::InputJson(final_value),
|
||||
))
|
||||
} else {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
fn finish_tool_output_item(
|
||||
state: &mut OpenAIResponsesState,
|
||||
output_index: usize,
|
||||
kind: ToolInputKind,
|
||||
call_id: String,
|
||||
name: String,
|
||||
final_value: Option<String>,
|
||||
) -> Result<Vec<Event>, ClientError> {
|
||||
let metadata = BlockMetadata::ToolUse { id: call_id, name };
|
||||
let mut out = if let Some(final_value) = final_value {
|
||||
reconcile_tool_input(state, output_index, kind, final_value, metadata.clone())?
|
||||
} else {
|
||||
let pending = ensure_tool_input_kind(state, output_index, kind)?;
|
||||
if pending.final_value.is_none() {
|
||||
return Err(tool_input_error(
|
||||
"tool_input_final_missing",
|
||||
format!(
|
||||
"OpenAI Responses {} ended without an authoritative final value at output index {output_index}",
|
||||
kind.label()
|
||||
),
|
||||
));
|
||||
}
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let (info, just_created) =
|
||||
state.get_or_allocate(SlotKey::OutputItem(output_index), BlockType::ToolUse);
|
||||
if just_created {
|
||||
out.push(Event::BlockStart(BlockStart {
|
||||
index: info.flat_index,
|
||||
block_type: BlockType::ToolUse,
|
||||
metadata,
|
||||
}));
|
||||
}
|
||||
state.slots.remove(&SlotKey::OutputItem(output_index));
|
||||
state.pending_tool_inputs.remove(&output_index);
|
||||
out.push(Event::BlockStop(BlockStop {
|
||||
index: info.flat_index,
|
||||
block_type: info.block_type,
|
||||
stop_reason: None,
|
||||
reasoning: None,
|
||||
}));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn ensure_tool_input_kind(
|
||||
state: &mut OpenAIResponsesState,
|
||||
output_index: usize,
|
||||
kind: ToolInputKind,
|
||||
) -> Result<&mut PendingToolInput, ClientError> {
|
||||
let pending = state
|
||||
.pending_tool_inputs
|
||||
.entry(output_index)
|
||||
.or_insert_with(|| PendingToolInput::new(kind));
|
||||
if pending.kind != kind {
|
||||
return Err(tool_input_error(
|
||||
"tool_input_kind_mismatch",
|
||||
format!("OpenAI Responses tool input kind changed at output index {output_index}"),
|
||||
));
|
||||
}
|
||||
Ok(pending)
|
||||
}
|
||||
|
||||
fn tool_input_error(code: &str, message: String) -> ClientError {
|
||||
ClientError::Api {
|
||||
status: None,
|
||||
code: Some(code.to_string()),
|
||||
message,
|
||||
retry_after: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn response_failure_diagnostic(event_type: &str, ev: ResponseFailed) -> (Option<String>, String) {
|
||||
let mut diagnostic = Map::new();
|
||||
diagnostic.insert("event".to_string(), Value::String(event_type.to_string()));
|
||||
@@ -855,6 +1137,26 @@ mod tests {
|
||||
parse_sse(event_type, data, state).unwrap()
|
||||
}
|
||||
|
||||
fn input_json_deltas(events: &[Event]) -> Vec<&str> {
|
||||
events
|
||||
.iter()
|
||||
.filter_map(|event| match event {
|
||||
Event::BlockDelta(BlockDelta {
|
||||
delta: DeltaContent::InputJson(delta),
|
||||
..
|
||||
}) => Some(delta.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn api_error_code(error: ClientError) -> Option<String> {
|
||||
match error {
|
||||
ClientError::Api { code, .. } => code,
|
||||
other => panic!("expected ClientError::Api, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn created_emits_status_started() {
|
||||
let (events, _) = run("response.created", r#"{"response":{}}"#);
|
||||
@@ -981,6 +1283,19 @@ mod tests {
|
||||
if let Event::BlockDelta(d) = &ev[0] {
|
||||
assert!(matches!(&d.delta, DeltaContent::InputJson(j) if j == "{\"x\":"));
|
||||
}
|
||||
let ev = with(
|
||||
&mut state,
|
||||
"response.function_call_arguments.delta",
|
||||
r#"{"output_index":1,"item_id":"fc1","delta":"1}"}"#,
|
||||
);
|
||||
assert_eq!(ev.len(), 1);
|
||||
assert!(matches!(
|
||||
&ev[0],
|
||||
Event::BlockDelta(BlockDelta {
|
||||
delta: DeltaContent::InputJson(j),
|
||||
..
|
||||
}) if j == "1}"
|
||||
));
|
||||
// output_item.done → BlockStop
|
||||
let ev = with(
|
||||
&mut state,
|
||||
@@ -1012,6 +1327,155 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_call_done_payload_emits_missing_final_input_before_stop() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup"}}"#,
|
||||
);
|
||||
|
||||
let events = with(
|
||||
&mut state,
|
||||
"response.output_item.done",
|
||||
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"city\":\"Tokyo\"}"}}"#,
|
||||
);
|
||||
|
||||
assert_eq!(input_json_deltas(&events), vec![r#"{"city":"Tokyo"}"#]);
|
||||
assert!(matches!(events.last(), Some(Event::BlockStop(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_call_arguments_done_accepts_exact_stream_without_duplication() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup"}}"#,
|
||||
);
|
||||
let delta = with(
|
||||
&mut state,
|
||||
"response.function_call_arguments.delta",
|
||||
r#"{"output_index":0,"delta":"{\"city\":\"Tokyo\"}"}"#,
|
||||
);
|
||||
assert_eq!(input_json_deltas(&delta), vec![r#"{"city":"Tokyo"}"#]);
|
||||
|
||||
let done = with(
|
||||
&mut state,
|
||||
"response.function_call_arguments.done",
|
||||
r#"{"output_index":0,"arguments":"{\"city\":\"Tokyo\"}"}"#,
|
||||
);
|
||||
assert!(done.is_empty());
|
||||
let output_done = with(
|
||||
&mut state,
|
||||
"response.output_item.done",
|
||||
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup","arguments":"{\"city\":\"Tokyo\"}"}}"#,
|
||||
);
|
||||
assert!(input_json_deltas(&output_done).is_empty());
|
||||
assert!(matches!(output_done.as_slice(), [Event::BlockStop(_)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_call_arguments_done_rejects_stream_mismatch() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"function_call","call_id":"call_1","name":"lookup"}}"#,
|
||||
);
|
||||
with(
|
||||
&mut state,
|
||||
"response.function_call_arguments.delta",
|
||||
r#"{"output_index":0,"delta":"{\"city\":\"Kyoto\"}"}"#,
|
||||
);
|
||||
|
||||
let error = parse_sse(
|
||||
"response.function_call_arguments.done",
|
||||
r#"{"output_index":0,"arguments":"{\"city\":\"Tokyo\"}"}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
api_error_code(error).as_deref(),
|
||||
Some("tool_input_delta_mismatch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_tool_input_done_event_emits_missing_final_input_once() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell"}}"#,
|
||||
);
|
||||
|
||||
let done = with(
|
||||
&mut state,
|
||||
"response.custom_tool_call_input.done",
|
||||
r#"{"output_index":0,"input":"echo ready"}"#,
|
||||
);
|
||||
assert_eq!(input_json_deltas(&done), vec!["echo ready"]);
|
||||
let output_done = with(
|
||||
&mut state,
|
||||
"response.output_item.done",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell","input":"echo ready"}}"#,
|
||||
);
|
||||
assert!(input_json_deltas(&output_done).is_empty());
|
||||
assert!(matches!(output_done.as_slice(), [Event::BlockStop(_)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_tool_done_payload_accepts_exact_stream_without_duplication() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell"}}"#,
|
||||
);
|
||||
let delta = with(
|
||||
&mut state,
|
||||
"response.custom_tool_call_input.delta",
|
||||
r#"{"output_index":0,"delta":"echo ready"}"#,
|
||||
);
|
||||
assert_eq!(input_json_deltas(&delta), vec!["echo ready"]);
|
||||
|
||||
let output_done = with(
|
||||
&mut state,
|
||||
"response.output_item.done",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell","input":"echo ready"}}"#,
|
||||
);
|
||||
assert!(input_json_deltas(&output_done).is_empty());
|
||||
assert!(matches!(output_done.as_slice(), [Event::BlockStop(_)]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_tool_done_payload_rejects_stream_mismatch() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
with(
|
||||
&mut state,
|
||||
"response.output_item.added",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell"}}"#,
|
||||
);
|
||||
with(
|
||||
&mut state,
|
||||
"response.custom_tool_call_input.delta",
|
||||
r#"{"output_index":0,"delta":"echo stale"}"#,
|
||||
);
|
||||
|
||||
let error = parse_sse(
|
||||
"response.output_item.done",
|
||||
r#"{"output_index":0,"item":{"type":"custom_tool_call","call_id":"call_1","name":"shell","input":"echo ready"}}"#,
|
||||
&mut state,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
api_error_code(error).as_deref(),
|
||||
Some("tool_input_delta_mismatch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reasoning_text_delta_emits_thinking() {
|
||||
let mut state = OpenAIResponsesState::default();
|
||||
|
||||
@@ -254,6 +254,7 @@ fn convert_items_to_input(items: &[Item], supports_images: bool) -> Vec<InputIte
|
||||
let mut out = Vec::with_capacity(items.len());
|
||||
for item in items {
|
||||
match item {
|
||||
Item::AssistantResponseBoundary { .. } => {}
|
||||
Item::Message { role, content, .. } => {
|
||||
let (role_str, text_variant): (&'static str, fn(String) -> InputContent) =
|
||||
match role {
|
||||
|
||||
@@ -6,7 +6,7 @@ use crate::llm_client::{
|
||||
ClientError,
|
||||
auth::AuthRequirement,
|
||||
capability::ModelCapability,
|
||||
client::ConfigWarning,
|
||||
client::{ConfigWarning, ToolCallCompletionSupport},
|
||||
event::Event,
|
||||
scheme::Scheme,
|
||||
types::{Request, RequestConfig},
|
||||
@@ -56,6 +56,10 @@ impl Scheme for OpenAIResponsesScheme {
|
||||
super::capability::default_capability()
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
ToolCallCompletionSupport::PerBlock
|
||||
}
|
||||
|
||||
fn validate_config(&self, config: &RequestConfig) -> Vec<ConfigWarning> {
|
||||
let mut warnings = Vec::new();
|
||||
// Some compatible backends reject `max_output_tokens` with HTTP 400.
|
||||
|
||||
@@ -510,6 +510,10 @@ impl<S: Scheme + Clone + 'static> LlmClient for HttpTransport<S> {
|
||||
self.scheme.validate_config(config)
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> super::ToolCallCompletionSupport {
|
||||
self.scheme.tool_call_completion_support()
|
||||
}
|
||||
|
||||
async fn stream(&self, request: Request) -> Result<ResponseStream, ClientError> {
|
||||
let total_started = Instant::now();
|
||||
let path = self.scheme.path(&self.model_id);
|
||||
|
||||
@@ -93,6 +93,12 @@ pub enum Item {
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
|
||||
/// Internal durable boundary used to reconstruct one assistant response when
|
||||
/// early ToolResults are committed before the response stream has ended.
|
||||
/// Provider request projections remove this item before serialization.
|
||||
#[doc(hidden)]
|
||||
AssistantResponseBoundary { response_id: String },
|
||||
|
||||
/// Tool call from the assistant
|
||||
ToolCall {
|
||||
/// Optional item ID
|
||||
@@ -104,7 +110,15 @@ pub enum Item {
|
||||
name: String,
|
||||
/// Tool arguments as JSON string
|
||||
arguments: String,
|
||||
/// Item status
|
||||
/// Provider/model order among tool calls in this assistant response.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
call_index: Option<usize>,
|
||||
/// Durable identity of an execution or synthetic terminalization that
|
||||
/// owns this call. Once present, restore must never infer the call pending.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
execution_id: Option<String>,
|
||||
/// Item status. `InProgress` on a persisted ToolCall means execution or
|
||||
/// terminalization ownership was transferred and must never be inferred pending.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
@@ -219,6 +233,14 @@ impl Item {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create an internal durable assistant-response boundary.
|
||||
#[doc(hidden)]
|
||||
pub fn assistant_response_boundary(response_id: impl Into<String>) -> Self {
|
||||
Self::AssistantResponseBoundary {
|
||||
response_id: response_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
// ========================================================================
|
||||
// Tool call constructors
|
||||
// ========================================================================
|
||||
@@ -234,6 +256,8 @@ impl Item {
|
||||
call_id: call_id.into(),
|
||||
name: name.into(),
|
||||
arguments: arguments.into(),
|
||||
call_index: None,
|
||||
execution_id: None,
|
||||
status: None,
|
||||
}
|
||||
}
|
||||
@@ -247,6 +271,25 @@ impl Item {
|
||||
Self::tool_call(call_id, name, arguments.to_string())
|
||||
}
|
||||
|
||||
/// Attach response-local ordering and, when execution has started, its
|
||||
/// durable attempt identity to a tool call.
|
||||
pub fn with_tool_execution_metadata(
|
||||
mut self,
|
||||
call_index: usize,
|
||||
execution_id: Option<String>,
|
||||
) -> Self {
|
||||
if let Self::ToolCall {
|
||||
call_index: item_call_index,
|
||||
execution_id: item_execution_id,
|
||||
..
|
||||
} = &mut self
|
||||
{
|
||||
*item_call_index = Some(call_index);
|
||||
*item_execution_id = execution_id;
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a tool result item with summary only (no content).
|
||||
pub fn tool_result(call_id: impl Into<String>, summary: impl Into<String>) -> Self {
|
||||
Self::tool_result_item(call_id, summary, None, false)
|
||||
@@ -377,6 +420,7 @@ impl Item {
|
||||
pub fn with_id(mut self, id: impl Into<String>) -> Self {
|
||||
match &mut self {
|
||||
Self::Message { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::AssistantResponseBoundary { .. } => {}
|
||||
Self::ToolCall { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::ToolResult { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
Self::Reasoning { id: item_id, .. } => *item_id = Some(id.into()),
|
||||
@@ -388,6 +432,7 @@ impl Item {
|
||||
pub fn with_status(mut self, new_status: ItemStatus) -> Self {
|
||||
match &mut self {
|
||||
Self::Message { status, .. } => *status = Some(new_status),
|
||||
Self::AssistantResponseBoundary { .. } => {}
|
||||
Self::ToolCall { status, .. } => *status = Some(new_status),
|
||||
Self::ToolResult { .. } => {} // Result items don't have status
|
||||
Self::Reasoning { status, .. } => *status = Some(new_status),
|
||||
@@ -403,6 +448,7 @@ impl Item {
|
||||
pub fn id(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Message { id, .. } => id.as_deref(),
|
||||
Self::AssistantResponseBoundary { .. } => None,
|
||||
Self::ToolCall { id, .. } => id.as_deref(),
|
||||
Self::ToolResult { id, .. } => id.as_deref(),
|
||||
Self::Reasoning { id, .. } => id.as_deref(),
|
||||
@@ -413,6 +459,7 @@ impl Item {
|
||||
pub fn item_type(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Message { .. } => "message",
|
||||
Self::AssistantResponseBoundary { .. } => "assistant_response_boundary",
|
||||
Self::ToolCall { .. } => "tool_call",
|
||||
Self::ToolResult { .. } => "tool_result",
|
||||
Self::Reasoning { .. } => "reasoning",
|
||||
|
||||
@@ -21,6 +21,7 @@ pub use event::*;
|
||||
pub use text_block_collector::TextBlockCollector;
|
||||
pub use thinking_block_collector::ThinkingBlockCollector;
|
||||
pub use timeline::Timeline;
|
||||
pub(crate) use tool_call_collector::CollectedToolCall;
|
||||
pub use tool_call_collector::ToolCallCollector;
|
||||
|
||||
// 型定義からのre-export
|
||||
|
||||
@@ -256,8 +256,8 @@ where
|
||||
H: Handler<ToolUseBlockKind>,
|
||||
{
|
||||
handler: H,
|
||||
scope: Option<H::Scope>,
|
||||
current_tool: Option<(String, String)>, // (id, name)
|
||||
scopes: HashMap<usize, H::Scope>,
|
||||
tools: HashMap<usize, (String, String)>, // index -> (id, name)
|
||||
}
|
||||
|
||||
impl<H> ToolUseBlockHandlerWrapper<H>
|
||||
@@ -267,8 +267,8 @@ where
|
||||
fn new(handler: H) -> Self {
|
||||
Self {
|
||||
handler,
|
||||
scope: None,
|
||||
current_tool: None,
|
||||
scopes: HashMap::new(),
|
||||
tools: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,9 +279,9 @@ where
|
||||
H::Scope: Send + Sync,
|
||||
{
|
||||
fn dispatch_start(&mut self, start: &BlockStart) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let BlockMetadata::ToolUse { id, name } = &start.metadata {
|
||||
self.current_tool = Some((id.clone(), name.clone()));
|
||||
let scope = self.scopes.entry(start.index).or_default();
|
||||
self.tools.insert(start.index, (id.clone(), name.clone()));
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&ToolUseBlockEvent::Start(ToolUseBlockStart {
|
||||
@@ -292,22 +292,22 @@ where
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_delta(&mut self, delta: &BlockDelta) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let DeltaContent::InputJson(json) = &delta.delta {
|
||||
let scope = self.scopes.entry(delta.index).or_default();
|
||||
self.handler
|
||||
.on_event(scope, &ToolUseBlockEvent::InputJsonDelta(json.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_stop(&mut self, stop: &BlockStop) {
|
||||
if let Some(scope) = &mut self.scope {
|
||||
if let Some((id, name)) = self.current_tool.take() {
|
||||
if let (Some(mut scope), Some((id, name))) = (
|
||||
self.scopes.remove(&stop.index),
|
||||
self.tools.remove(&stop.index),
|
||||
) {
|
||||
self.handler.on_event(
|
||||
scope,
|
||||
&mut scope,
|
||||
&ToolUseBlockEvent::Stop(ToolUseBlockStop {
|
||||
index: stop.index,
|
||||
id,
|
||||
@@ -316,23 +316,18 @@ where
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_abort(&mut self, abort: &BlockAbort) {
|
||||
self.scopes.remove(&abort.index);
|
||||
self.tools.remove(&abort.index);
|
||||
}
|
||||
|
||||
fn dispatch_abort(&mut self, _abort: &BlockAbort) {
|
||||
self.current_tool = None;
|
||||
}
|
||||
fn start_scope(&mut self) {}
|
||||
|
||||
fn start_scope(&mut self) {
|
||||
self.scope = Some(H::Scope::default());
|
||||
}
|
||||
|
||||
fn end_scope(&mut self) {
|
||||
self.scope = None;
|
||||
self.current_tool = None;
|
||||
}
|
||||
fn end_scope(&mut self) {}
|
||||
|
||||
fn has_scope(&self) -> bool {
|
||||
self.scope.is_some()
|
||||
!self.scopes.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +374,9 @@ pub struct Timeline {
|
||||
thinking_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
|
||||
tool_use_block_handlers: Vec<Box<dyn ErasedBlockHandler>>,
|
||||
|
||||
// 現在アクティブなブロック
|
||||
// 現在アクティブなブロック。provider は index ごとに複数 scope を
|
||||
// interleave できるため、単一の current block ではなく全 scope を追跡する。
|
||||
active_blocks: HashMap<(BlockType, usize), ()>,
|
||||
current_block: Option<BlockType>,
|
||||
|
||||
// 1リクエスト内で受信した Usage event の集約バッファ。
|
||||
@@ -405,6 +402,7 @@ impl Timeline {
|
||||
text_block_handlers: Vec::new(),
|
||||
thinking_block_handlers: Vec::new(),
|
||||
tool_use_block_handlers: Vec::new(),
|
||||
active_blocks: HashMap::new(),
|
||||
current_block: None,
|
||||
pending_usage: None,
|
||||
}
|
||||
@@ -559,6 +557,8 @@ impl Timeline {
|
||||
}
|
||||
|
||||
fn handle_block_start(&mut self, start: &BlockStart) {
|
||||
self.active_blocks
|
||||
.insert((start.block_type, start.index), ());
|
||||
self.current_block = Some(start.block_type);
|
||||
|
||||
let handlers = self.get_block_handlers_mut(start.block_type);
|
||||
@@ -572,10 +572,11 @@ impl Timeline {
|
||||
let block_type = delta.delta.block_type();
|
||||
|
||||
// OpenAIなどのプロバイダはBlockStartを送らない場合があるため、
|
||||
// Deltaが来たときにスコープがなければ暗黙的に開始する
|
||||
if self.current_block.is_none() {
|
||||
// Deltaが来たときにその index のスコープがなければ暗黙的に開始する
|
||||
self.active_blocks
|
||||
.entry((block_type, delta.index))
|
||||
.or_insert(());
|
||||
self.current_block = Some(block_type);
|
||||
}
|
||||
|
||||
let handlers = self.get_block_handlers_mut(block_type);
|
||||
for handler in handlers {
|
||||
@@ -593,7 +594,12 @@ impl Timeline {
|
||||
handler.dispatch_stop(stop);
|
||||
handler.end_scope();
|
||||
}
|
||||
self.current_block = None;
|
||||
self.active_blocks.remove(&(stop.block_type, stop.index));
|
||||
self.current_block = self
|
||||
.active_blocks
|
||||
.keys()
|
||||
.next()
|
||||
.map(|(block_type, _)| *block_type);
|
||||
}
|
||||
|
||||
fn handle_block_abort(&mut self, abort: &BlockAbort) {
|
||||
@@ -602,7 +608,12 @@ impl Timeline {
|
||||
handler.dispatch_abort(abort);
|
||||
handler.end_scope();
|
||||
}
|
||||
self.current_block = None;
|
||||
self.active_blocks.remove(&(abort.block_type, abort.index));
|
||||
self.current_block = self
|
||||
.active_blocks
|
||||
.keys()
|
||||
.next()
|
||||
.map(|(block_type, _)| *block_type);
|
||||
}
|
||||
|
||||
fn get_block_handlers_mut(
|
||||
@@ -622,19 +633,22 @@ impl Timeline {
|
||||
self.current_block
|
||||
}
|
||||
|
||||
/// 現在アクティブなブロックを中断する
|
||||
/// 現在アクティブな全ブロックを中断する
|
||||
///
|
||||
/// キャンセルやエラー時に呼び出し、進行中のブロックに対して
|
||||
/// キャンセルやエラー時に呼び出し、index ごとに進行中の全ブロックへ
|
||||
/// BlockAbortイベントを発火してスコープをクリーンアップする。
|
||||
pub fn abort_current_block(&mut self) {
|
||||
if let Some(block_type) = self.current_block {
|
||||
let active = self.active_blocks.keys().copied().collect::<Vec<_>>();
|
||||
for (block_type, index) in active {
|
||||
let abort = crate::timeline::event::BlockAbort {
|
||||
index: 0, // インデックスは不明なので0
|
||||
index,
|
||||
block_type,
|
||||
reason: "Cancelled".to_string(),
|
||||
};
|
||||
self.handle_block_abort(&abort);
|
||||
}
|
||||
self.active_blocks.clear();
|
||||
self.current_block = None;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -679,6 +693,50 @@ mod tests {
|
||||
assert!(calls.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abort_clears_every_indexed_scope_before_indices_are_reused() {
|
||||
#[derive(Default)]
|
||||
struct Scope {
|
||||
started: bool,
|
||||
}
|
||||
struct IndexedToolHandler {
|
||||
reused_cleanly: Arc<Mutex<Vec<bool>>>,
|
||||
}
|
||||
impl Handler<ToolUseBlockKind> for IndexedToolHandler {
|
||||
type Scope = Scope;
|
||||
|
||||
fn on_event(&mut self, scope: &mut Scope, event: &ToolUseBlockEvent) {
|
||||
if matches!(event, ToolUseBlockEvent::Start(_)) {
|
||||
self.reused_cleanly.lock().unwrap().push(!scope.started);
|
||||
scope.started = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let reused_cleanly = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(IndexedToolHandler {
|
||||
reused_cleanly: reused_cleanly.clone(),
|
||||
});
|
||||
|
||||
timeline.dispatch(&Event::tool_use_start(0, "first", "tool"));
|
||||
timeline.dispatch(&Event::tool_use_start(1, "second", "tool"));
|
||||
// Stopping one interleaved block must not hide its still-active sibling.
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
assert_eq!(timeline.current_block(), Some(BlockType::ToolUse));
|
||||
timeline.abort_current_block();
|
||||
assert!(timeline.current_block().is_none());
|
||||
|
||||
// A later provider response may reuse the same content-block index.
|
||||
timeline.dispatch(&Event::tool_use_start(1, "replacement", "tool"));
|
||||
timeline.dispatch(&Event::tool_use_stop(1));
|
||||
|
||||
assert_eq!(
|
||||
reused_cleanly.lock().unwrap().as_slice(),
|
||||
[true, true, true]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_meta_event_dispatch() {
|
||||
// シンプルなテスト用構造体
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
use crate::{
|
||||
handler::{Handler, ToolUseBlockEvent, ToolUseBlockKind},
|
||||
llm_client::types::parse_tool_arguments,
|
||||
tool::ToolCall,
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@@ -18,10 +17,19 @@ pub struct CollectorState {
|
||||
/// 現在のツール呼び出し情報 (ブロック進行中)
|
||||
current_id: Option<String>,
|
||||
current_name: Option<String>,
|
||||
/// Zero-based order among tool calls in this assistant response.
|
||||
call_index: Option<usize>,
|
||||
/// 蓄積中のJSON入力
|
||||
input_json_buffer: String,
|
||||
}
|
||||
|
||||
/// One provider-completed call together with its stable model order.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct CollectedToolCall {
|
||||
pub call_index: usize,
|
||||
pub call: ToolCall,
|
||||
}
|
||||
|
||||
/// ToolCallCollector - ToolUseブロックハンドラ
|
||||
///
|
||||
/// Timelineに登録してToolUseブロックイベントを受信し、
|
||||
@@ -29,7 +37,8 @@ pub struct CollectorState {
|
||||
#[derive(Clone)]
|
||||
pub struct ToolCallCollector {
|
||||
/// 収集されたToolCall
|
||||
collected: Arc<Mutex<Vec<ToolCall>>>,
|
||||
collected: Arc<Mutex<Vec<CollectedToolCall>>>,
|
||||
next_call_index: Arc<Mutex<usize>>,
|
||||
}
|
||||
|
||||
impl ToolCallCollector {
|
||||
@@ -37,18 +46,38 @@ impl ToolCallCollector {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
collected: Arc::new(Mutex::new(Vec::new())),
|
||||
next_call_index: Arc::new(Mutex::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 収集されたToolCallを取得してクリア
|
||||
pub fn take_collected(&self) -> Vec<ToolCall> {
|
||||
self.take_collected_with_index()
|
||||
.into_iter()
|
||||
.map(|collected| collected.call)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Drain completed calls while preserving their model-returned order.
|
||||
pub(crate) fn take_collected_with_index(&self) -> Vec<CollectedToolCall> {
|
||||
let mut guard = self.collected.lock().unwrap();
|
||||
std::mem::take(&mut *guard)
|
||||
}
|
||||
|
||||
/// 収集されたToolCallの参照を取得
|
||||
pub fn collected(&self) -> Vec<ToolCall> {
|
||||
self.collected.lock().unwrap().clone()
|
||||
self.collected
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|collected| collected.call.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Start one response-local tool-call ordering scope.
|
||||
pub(crate) fn begin_response(&self) {
|
||||
self.collected.lock().unwrap().clear();
|
||||
*self.next_call_index.lock().unwrap() = 0;
|
||||
}
|
||||
|
||||
/// 収集されたToolCallがあるかどうか
|
||||
@@ -76,6 +105,11 @@ impl Handler<ToolUseBlockKind> for ToolCallCollector {
|
||||
ToolUseBlockEvent::Start(start) => {
|
||||
scope.current_id = Some(start.id.clone());
|
||||
scope.current_name = Some(start.name.clone());
|
||||
if scope.call_index.is_none() {
|
||||
let mut next = self.next_call_index.lock().unwrap();
|
||||
scope.call_index = Some(*next);
|
||||
*next += 1;
|
||||
}
|
||||
scope.input_json_buffer.clear();
|
||||
}
|
||||
ToolUseBlockEvent::InputJsonDelta(delta) => {
|
||||
@@ -85,11 +119,29 @@ impl Handler<ToolUseBlockKind> for ToolCallCollector {
|
||||
// ブロック完了時にToolCallを確定
|
||||
if let (Some(id), Some(name)) = (scope.current_id.take(), scope.current_name.take())
|
||||
{
|
||||
let input = parse_tool_arguments(&scope.input_json_buffer);
|
||||
// A Stop event proves the provider finished the block, but
|
||||
// malformed/non-object arguments are still not executable.
|
||||
// Preserve an explicit null sentinel so the engine can commit
|
||||
// a terminal validation error without guessing `{}`.
|
||||
let input = if scope.input_json_buffer.trim().is_empty() {
|
||||
serde_json::Value::Object(serde_json::Map::new())
|
||||
} else {
|
||||
serde_json::from_str::<serde_json::Value>(&scope.input_json_buffer)
|
||||
.ok()
|
||||
.filter(serde_json::Value::is_object)
|
||||
.unwrap_or(serde_json::Value::Null)
|
||||
};
|
||||
|
||||
let tool_call = ToolCall { id, name, input };
|
||||
let call_index = scope
|
||||
.call_index
|
||||
.take()
|
||||
.expect("tool-use stop follows a started collector scope");
|
||||
|
||||
self.collected.lock().unwrap().push(tool_call);
|
||||
self.collected.lock().unwrap().push(CollectedToolCall {
|
||||
call_index,
|
||||
call: tool_call,
|
||||
});
|
||||
}
|
||||
scope.input_json_buffer.clear();
|
||||
}
|
||||
@@ -144,6 +196,43 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_interleaved_tool_call_indexes_and_ignore_duplicate_stop() {
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
|
||||
timeline.dispatch(&Event::tool_use_start(0, "call_0", "tool_0"));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"{"a":"#));
|
||||
timeline.dispatch(&Event::tool_use_start(1, "call_1", "tool_1"));
|
||||
timeline.dispatch(&Event::tool_input_delta(1, r#"{"b":2}"#));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"1}"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(1));
|
||||
timeline.dispatch(&Event::tool_use_stop(1));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 2);
|
||||
assert_eq!(calls[0].id, "call_1");
|
||||
assert_eq!(calls[0].input, serde_json::json!({"b": 2}));
|
||||
assert_eq!(calls[1].id, "call_0");
|
||||
assert_eq!(calls[1].input, serde_json::json!({"a": 1}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_completed_arguments_are_not_normalized_to_executable_object() {
|
||||
let collector = ToolCallCollector::new();
|
||||
let mut timeline = Timeline::new();
|
||||
timeline.on_tool_use_block(collector.clone());
|
||||
timeline.dispatch(&Event::tool_use_start(0, "bad", "mutate"));
|
||||
timeline.dispatch(&Event::tool_input_delta(0, r#"{"unterminated":"#));
|
||||
timeline.dispatch(&Event::tool_use_stop(0));
|
||||
|
||||
let calls = collector.take_collected();
|
||||
assert_eq!(calls.len(), 1);
|
||||
assert!(calls[0].input.is_null());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_collect_multiple_tool_calls() {
|
||||
let collector = ToolCallCollector::new();
|
||||
|
||||
@@ -36,6 +36,7 @@ async fn run_preserves_item_annotations_without_projecting_them() {
|
||||
Role::Assistant => "assistant",
|
||||
Role::System => "system",
|
||||
},
|
||||
Item::AssistantResponseBoundary { .. } => "assistant_response_boundary",
|
||||
Item::ToolCall { .. } => "tool_call",
|
||||
Item::ToolResult { .. } => "tool_result",
|
||||
Item::Reasoning { .. } => "reasoning",
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use agen::llm_client::event::{BlockType, DeltaContent, Event};
|
||||
use agen::llm_client::{ClientError, LlmClient, Request};
|
||||
use agen::llm_client::{ClientError, LlmClient, Request, ToolCallCompletionSupport};
|
||||
use agen::timeline::{Handler, TextBlockEvent, TextBlockKind, Timeline};
|
||||
use async_trait::async_trait;
|
||||
use futures::Stream;
|
||||
@@ -20,6 +20,7 @@ pub struct MockLlmClient {
|
||||
responses: Arc<Vec<Vec<Event>>>,
|
||||
call_count: Arc<AtomicUsize>,
|
||||
requests: Arc<Mutex<Vec<Request>>>,
|
||||
completion_support: ToolCallCompletionSupport,
|
||||
}
|
||||
|
||||
impl MockLlmClient {
|
||||
@@ -32,6 +33,7 @@ impl MockLlmClient {
|
||||
responses: Arc::new(responses),
|
||||
call_count: Arc::new(AtomicUsize::new(0)),
|
||||
requests: Arc::new(Mutex::new(Vec::new())),
|
||||
completion_support: ToolCallCompletionSupport::ResponseComplete,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +49,11 @@ impl MockLlmClient {
|
||||
pub fn requests(&self) -> Vec<Request> {
|
||||
self.requests.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
pub fn with_completion_support(mut self, support: ToolCallCompletionSupport) -> Self {
|
||||
self.completion_support = support;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@@ -55,6 +62,10 @@ impl LlmClient for MockLlmClient {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
self.completion_support
|
||||
}
|
||||
|
||||
async fn stream(
|
||||
&self,
|
||||
request: Request,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,142 @@
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
};
|
||||
|
||||
use agen::llm_client::{
|
||||
ClientError, LlmClient, Request, ResponseStream, ToolCallCompletionSupport,
|
||||
scheme::{Scheme, gemini::GeminiScheme, openai_chat::OpenAIScheme},
|
||||
};
|
||||
use agen::{Engine, EngineRunExit, History, ToolCallDispatchMode};
|
||||
use async_trait::async_trait;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SseFrame {
|
||||
event_type: &'static str,
|
||||
data: &'static str,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SchemeReplayClient<S> {
|
||||
scheme: S,
|
||||
responses: Arc<Vec<Vec<SseFrame>>>,
|
||||
stream_calls: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl<S> SchemeReplayClient<S> {
|
||||
fn new(scheme: S, responses: Vec<Vec<SseFrame>>) -> Self {
|
||||
Self {
|
||||
scheme,
|
||||
responses: Arc::new(responses),
|
||||
stream_calls: Arc::new(AtomicUsize::new(0)),
|
||||
}
|
||||
}
|
||||
|
||||
fn stream_count(&self) -> usize {
|
||||
self.stream_calls.load(Ordering::SeqCst)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S: Scheme> LlmClient for SchemeReplayClient<S> {
|
||||
async fn stream(&self, _request: Request) -> Result<ResponseStream, ClientError> {
|
||||
let response_index = self.stream_calls.fetch_add(1, Ordering::SeqCst);
|
||||
let frames = self
|
||||
.responses
|
||||
.get(response_index)
|
||||
.ok_or_else(|| ClientError::Config("scheme replay responses exhausted".to_string()))?;
|
||||
let mut state = S::State::default();
|
||||
let mut output = Vec::new();
|
||||
for frame in frames {
|
||||
match self
|
||||
.scheme
|
||||
.parse_sse(frame.event_type, frame.data, &mut state)
|
||||
{
|
||||
Ok(events) => output.extend(events.into_iter().map(Ok)),
|
||||
Err(error) => {
|
||||
output.push(Err(error));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Box::pin(futures::stream::iter(output)))
|
||||
}
|
||||
|
||||
fn clone_boxed(&self) -> Box<dyn LlmClient> {
|
||||
Box::new(self.clone())
|
||||
}
|
||||
|
||||
fn tool_call_completion_support(&self) -> ToolCallCompletionSupport {
|
||||
self.scheme.tool_call_completion_support()
|
||||
}
|
||||
}
|
||||
|
||||
async fn assert_response_complete_scheme_accepts_clean_eof<S: Scheme>(
|
||||
scheme: S,
|
||||
frames: Vec<SseFrame>,
|
||||
) {
|
||||
assert_eq!(
|
||||
scheme.tool_call_completion_support(),
|
||||
ToolCallCompletionSupport::ResponseComplete
|
||||
);
|
||||
|
||||
for mode in [
|
||||
ToolCallDispatchMode::AfterResponse,
|
||||
ToolCallDispatchMode::OnToolCallComplete,
|
||||
] {
|
||||
let client = SchemeReplayClient::new(scheme.clone(), vec![frames.clone()]);
|
||||
let probe = client.clone();
|
||||
let mut engine = Engine::new(client);
|
||||
engine.set_tool_call_dispatch_mode(mode);
|
||||
let mut history = History::new();
|
||||
|
||||
let output = engine.run(&mut history, "hello").await;
|
||||
|
||||
assert!(
|
||||
matches!(output.result, EngineRunExit::Finished),
|
||||
"{mode:?} should accept the response-complete scheme's clean EOF, got {:?}",
|
||||
output.result
|
||||
);
|
||||
assert_eq!(
|
||||
probe.stream_count(),
|
||||
1,
|
||||
"{mode:?} must not open a continuation request after the adapter's normal EOF"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openai_chat_clean_eof_completes_in_default_and_opt_in_fallback_modes() {
|
||||
assert_response_complete_scheme_accepts_clean_eof(
|
||||
OpenAIScheme::new(),
|
||||
vec![
|
||||
SseFrame {
|
||||
event_type: "message",
|
||||
data: r#"{"id":"chatcmpl-123","object":"chat.completion.chunk","created":1694268190,"model":"gpt-4o","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":"stop"}]}"#,
|
||||
},
|
||||
SseFrame {
|
||||
event_type: "message",
|
||||
data: "[DONE]",
|
||||
},
|
||||
],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gemini_clean_eof_completes_in_default_and_opt_in_fallback_modes() {
|
||||
assert_response_complete_scheme_accepts_clean_eof(
|
||||
GeminiScheme::new(),
|
||||
vec![
|
||||
SseFrame {
|
||||
event_type: "message",
|
||||
data: r#"{"candidates":[{"content":{"parts":[{"text":"Hello"}],"role":"model"},"finishReason":"STOP","index":0}]}"#,
|
||||
},
|
||||
SseFrame {
|
||||
event_type: "message",
|
||||
data: "[DONE]",
|
||||
},
|
||||
],
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -542,6 +542,8 @@ pub struct EngineManifestConfig {
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<ReasoningControl>,
|
||||
#[serde(default)]
|
||||
pub tool_call_dispatch: Option<crate::ToolCallDispatchMode>,
|
||||
#[serde(default)]
|
||||
pub tool_output: ToolOutputLimitsPartial,
|
||||
#[serde(default)]
|
||||
pub file_upload: FileUploadLimitsPartial,
|
||||
@@ -868,6 +870,7 @@ impl EngineManifestConfig {
|
||||
top_k: upper.top_k.or(self.top_k),
|
||||
stop_sequences: upper.stop_sequences.or(self.stop_sequences),
|
||||
reasoning: upper.reasoning.or(self.reasoning),
|
||||
tool_call_dispatch: upper.tool_call_dispatch.or(self.tool_call_dispatch),
|
||||
tool_output: self.tool_output.merge(upper.tool_output),
|
||||
file_upload: self.file_upload.merge(upper.file_upload),
|
||||
}
|
||||
@@ -1171,6 +1174,7 @@ impl TryFrom<WorkerManifestConfig> for WorkerManifest {
|
||||
top_k: cfg.engine.top_k,
|
||||
stop_sequences: cfg.engine.stop_sequences.unwrap_or_default(),
|
||||
reasoning: cfg.engine.reasoning,
|
||||
tool_call_dispatch: cfg.engine.tool_call_dispatch.unwrap_or_default(),
|
||||
tool_output: ToolOutputLimits {
|
||||
default_max_bytes: cfg
|
||||
.engine
|
||||
@@ -1343,6 +1347,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_call_dispatch_defaults_merges_and_round_trips() {
|
||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||
assert_eq!(
|
||||
manifest.engine.tool_call_dispatch,
|
||||
crate::ToolCallDispatchMode::AfterResponse
|
||||
);
|
||||
|
||||
let lower = WorkerManifestConfig {
|
||||
engine: EngineManifestConfig {
|
||||
tool_call_dispatch: Some(crate::ToolCallDispatchMode::OnToolCallComplete),
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
let upper = WorkerManifestConfig::default();
|
||||
assert_eq!(
|
||||
lower.merge(upper).engine.tool_call_dispatch,
|
||||
Some(crate::ToolCallDispatchMode::OnToolCallComplete)
|
||||
);
|
||||
|
||||
let mut configured = minimal_valid();
|
||||
configured.engine.tool_call_dispatch =
|
||||
Some(crate::ToolCallDispatchMode::OnToolCallComplete);
|
||||
let manifest: WorkerManifest = configured.try_into().unwrap();
|
||||
let snapshot = serde_json::to_value(&manifest).unwrap();
|
||||
let restored: WorkerManifest = serde_json::from_value(snapshot).unwrap();
|
||||
assert_eq!(
|
||||
restored.engine.tool_call_dispatch,
|
||||
crate::ToolCallDispatchMode::OnToolCallComplete
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_minimal_succeeds() {
|
||||
let manifest: WorkerManifest = minimal_valid().try_into().unwrap();
|
||||
|
||||
@@ -587,6 +587,20 @@ pub struct WorkerMeta {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// When the engine may begin executing provider-confirmed tool calls.
|
||||
///
|
||||
/// `OnToolCallComplete` advances only the start time: calls may still overlap,
|
||||
/// so completion order and externally allocated identifiers are not ordered.
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ToolCallDispatchMode {
|
||||
/// Preserve the historical behavior: wait for the whole response stream.
|
||||
#[default]
|
||||
AfterResponse,
|
||||
/// Start each call once the provider emits a trustworthy per-call completion.
|
||||
OnToolCallComplete,
|
||||
}
|
||||
|
||||
/// Worker-level configuration embedded in the manifest.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EngineManifest {
|
||||
@@ -614,6 +628,9 @@ pub struct EngineManifest {
|
||||
pub stop_sequences: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub reasoning: Option<ReasoningControl>,
|
||||
/// Tool-call start timing. Omitted manifests preserve `after_response`.
|
||||
#[serde(default)]
|
||||
pub tool_call_dispatch: ToolCallDispatchMode,
|
||||
/// Byte-size caps applied to tool `content` before it reaches the
|
||||
/// conversation history. The section is optional in TOML — when
|
||||
/// omitted, `ToolOutputLimits::default()` (64 KiB default cap, no
|
||||
|
||||
@@ -43,7 +43,7 @@ fn render_items(items: &[Item]) -> String {
|
||||
Item::ToolResult { summary, .. } => {
|
||||
lines.push(format!("[ToolResult] {summary}"));
|
||||
}
|
||||
Item::Reasoning { .. } => {}
|
||||
Item::AssistantResponseBoundary { .. } | Item::Reasoning { .. } => {}
|
||||
}
|
||||
}
|
||||
lines.join("\n\n")
|
||||
|
||||
+106
-13
@@ -5083,8 +5083,15 @@ pub struct RuntimeWorkingDirectorySummary {
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum WorkingDirectorySource {
|
||||
Repository { repository_key: String },
|
||||
ExternalGrant { grant_id: String },
|
||||
Repository {
|
||||
repository_key: String,
|
||||
},
|
||||
ExternalGrant {
|
||||
grant_id: String,
|
||||
/// Operator-approved ceiling for this External Workdir grant. Worker
|
||||
/// attachment authority is projected separately after attenuation.
|
||||
grant_permissions: ExternalWorkdirPermissions,
|
||||
},
|
||||
}
|
||||
|
||||
/// Public, provider-neutral Workdir inventory projection.
|
||||
@@ -5212,6 +5219,16 @@ impl From<workdir::workspace::RuntimeWorkingDirectorySummary> for RuntimeWorking
|
||||
}
|
||||
}
|
||||
|
||||
impl From<workdir::workspace::WorkdirPermissionSummary> for ExternalWorkdirPermissions {
|
||||
fn from(value: workdir::workspace::WorkdirPermissionSummary) -> Self {
|
||||
Self {
|
||||
read: value.read,
|
||||
write: value.write,
|
||||
command: value.command,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySummary {
|
||||
fn from(value: workdir::workspace::WorkingDirectorySummary) -> Self {
|
||||
Self {
|
||||
@@ -5221,9 +5238,13 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
|
||||
workdir::workspace::WorkingDirectorySource::Repository { repository_key } => {
|
||||
WorkingDirectorySource::Repository { repository_key }
|
||||
}
|
||||
workdir::workspace::WorkingDirectorySource::ExternalGrant { grant_id } => {
|
||||
WorkingDirectorySource::ExternalGrant { grant_id }
|
||||
}
|
||||
workdir::workspace::WorkingDirectorySource::ExternalGrant {
|
||||
grant_id,
|
||||
grant_permissions,
|
||||
} => WorkingDirectorySource::ExternalGrant {
|
||||
grant_id,
|
||||
grant_permissions: grant_permissions.into(),
|
||||
},
|
||||
},
|
||||
creation_selector: value.creation_selector,
|
||||
creation_ref: value.creation_ref,
|
||||
@@ -5241,13 +5262,28 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExternalWorkdirPermission {
|
||||
#[default]
|
||||
ReadOnly,
|
||||
ReadWrite,
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExternalWorkdirPermissions {
|
||||
/// Read files and use read-only discovery operations such as Glob and Grep.
|
||||
pub read: bool,
|
||||
/// Create or update files with Write and Edit. Existing-file mutation also
|
||||
/// requires `read` so callers can satisfy the read-before-write contract.
|
||||
pub write: bool,
|
||||
/// Execute commands as the sharing CLI user on the provider host. This is
|
||||
/// explicit authority and does not imply either filesystem category.
|
||||
pub command: bool,
|
||||
}
|
||||
|
||||
impl Default for ExternalWorkdirPermissions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
read: true,
|
||||
write: false,
|
||||
command: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -5261,7 +5297,7 @@ pub struct ExternalWorkdirGrantCreateRequest {
|
||||
#[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))]
|
||||
#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))]
|
||||
pub ttl_seconds: Option<u64>,
|
||||
pub permission: ExternalWorkdirPermission,
|
||||
pub permissions: ExternalWorkdirPermissions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
@@ -5274,7 +5310,7 @@ pub struct ExternalWorkdirGrantResponse {
|
||||
pub working_directory_id: String,
|
||||
pub provider_instance_id: String,
|
||||
pub display_name: String,
|
||||
pub permission: ExternalWorkdirPermission,
|
||||
pub permissions: ExternalWorkdirPermissions,
|
||||
/// `None` means the grant has no automatic expiry. It remains bounded by
|
||||
/// explicit revoke, provider disconnect, and provider shutdown cleanup.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -7962,6 +7998,10 @@ pub struct WorkspaceWorkerDiscoveryPage {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkerWorkdirAttachmentSummary {
|
||||
pub alias: String,
|
||||
/// Current effective category authority after intersecting the durable link,
|
||||
/// source grant, and Ticket target ceilings. This is independent from
|
||||
/// provider liveness and may be narrower than the grant ceiling.
|
||||
pub effective_permissions: ExternalWorkdirPermissions,
|
||||
pub working_directory: WorkingDirectorySummary,
|
||||
}
|
||||
|
||||
@@ -11254,6 +11294,59 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_workdir_summary_separates_grant_and_attachment_effective_permissions() {
|
||||
let summary = WorkingDirectorySummary {
|
||||
working_directory_id: "external-1".into(),
|
||||
display_name: Some("Session data".into()),
|
||||
source: WorkingDirectorySource::ExternalGrant {
|
||||
grant_id: "grant-1".into(),
|
||||
grant_permissions: ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: false,
|
||||
command: true,
|
||||
},
|
||||
},
|
||||
creation_selector: None,
|
||||
creation_ref: None,
|
||||
creation_tree: None,
|
||||
current_selector: None,
|
||||
current_ref: None,
|
||||
current_tree: None,
|
||||
observed_at_epoch_seconds: None,
|
||||
materializer_kind: WorkingDirectoryMaterializerKind::ClientHostedExternal,
|
||||
cleanup_target: None,
|
||||
status: WorkingDirectoryStatusKind::Active,
|
||||
cleanliness: Some("unknown".into()),
|
||||
occupied_by: None,
|
||||
};
|
||||
let value = serde_json::to_value(WorkerWorkdirAttachmentSummary {
|
||||
alias: "sessions".into(),
|
||||
effective_permissions: ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: true,
|
||||
},
|
||||
working_directory: summary,
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
value["working_directory"]["source"]["grant_permissions"],
|
||||
serde_json::json!({"read": true, "write": false, "command": true})
|
||||
);
|
||||
assert_eq!(
|
||||
value["effective_permissions"],
|
||||
serde_json::json!({"read": false, "write": false, "command": true})
|
||||
);
|
||||
let mut missing = value;
|
||||
missing["working_directory"]["source"]
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.remove("grant_permissions");
|
||||
assert!(serde_json::from_value::<WorkerWorkdirAttachmentSummary>(missing).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_response_rejects_stale_occupancy_shape() {
|
||||
let stale = serde_json::json!({
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
//! save / replay boundaries.
|
||||
//!
|
||||
//! Fields kept here are limited to what is needed to reconstruct a worker
|
||||
//! `Item` for replay. `id` and `status` annotations are intentionally dropped
|
||||
//! (they are output-side metadata; replayed items synthesize fresh `None`).
|
||||
//! `Item` for replay. `id` annotations are intentionally dropped because they
|
||||
//! are output-side metadata. ToolCall execution status/order are retained because
|
||||
//! they fence durable resume from replaying an already-started side effect.
|
||||
//! `Reasoning::encrypted_content` is preserved because OpenAI Responses ZDR
|
||||
//! requires it on stateless re-send.
|
||||
|
||||
use agen::{
|
||||
llm_client::types::{ContentPart, Item, Role},
|
||||
llm_client::types::{ContentPart, Item, ItemStatus, Role},
|
||||
tool::{Attachment, ImageAttachment, ToolResultDisposition},
|
||||
};
|
||||
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||
@@ -49,10 +50,19 @@ pub enum LoggedItem {
|
||||
role: LoggedRole,
|
||||
content: Vec<LoggedContentPart>,
|
||||
},
|
||||
AssistantResponseBoundary {
|
||||
response_id: String,
|
||||
},
|
||||
ToolCall {
|
||||
call_id: String,
|
||||
name: String,
|
||||
arguments: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
call_index: Option<usize>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
execution_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
status: Option<ItemStatus>,
|
||||
},
|
||||
ToolResult {
|
||||
call_id: String,
|
||||
@@ -115,15 +125,24 @@ impl From<&Item> for LoggedItem {
|
||||
role: (*role).into(),
|
||||
content: content.iter().map(LoggedContentPart::from).collect(),
|
||||
},
|
||||
Item::AssistantResponseBoundary { response_id } => Self::AssistantResponseBoundary {
|
||||
response_id: response_id.clone(),
|
||||
},
|
||||
Item::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
call_index,
|
||||
execution_id,
|
||||
status,
|
||||
..
|
||||
} => Self::ToolCall {
|
||||
call_id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
arguments: arguments.clone(),
|
||||
call_index: *call_index,
|
||||
execution_id: execution_id.clone(),
|
||||
status: *status,
|
||||
},
|
||||
Item::ToolResult {
|
||||
call_id,
|
||||
@@ -172,16 +191,24 @@ impl From<LoggedItem> for Item {
|
||||
content: content.into_iter().map(Into::into).collect(),
|
||||
status: None,
|
||||
},
|
||||
LoggedItem::AssistantResponseBoundary { response_id } => {
|
||||
Item::assistant_response_boundary(response_id)
|
||||
}
|
||||
LoggedItem::ToolCall {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
call_index,
|
||||
execution_id,
|
||||
status,
|
||||
} => Item::ToolCall {
|
||||
id: None,
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
status: None,
|
||||
call_index,
|
||||
execution_id,
|
||||
status,
|
||||
},
|
||||
LoggedItem::ToolResult {
|
||||
call_id,
|
||||
@@ -325,9 +352,23 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assistant_response_boundary_round_trips_for_provider_projection() {
|
||||
let original = Item::assistant_response_boundary("response-7");
|
||||
let logged: LoggedItem = (&original).into();
|
||||
let json = serde_json::to_string(&logged).unwrap();
|
||||
assert!(json.contains(r#""kind":"assistant_response_boundary""#));
|
||||
assert_eq!(
|
||||
Item::from(serde_json::from_str::<LoggedItem>(&json).unwrap()),
|
||||
original
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_tool_call() {
|
||||
let original = Item::tool_call("call_42", "get_weather", r#"{"city":"Tokyo"}"#);
|
||||
let original = Item::tool_call("call_42", "get_weather", r#"{"city":"Tokyo"}"#)
|
||||
.with_tool_execution_metadata(3, Some("batch:call_42".to_string()))
|
||||
.with_status(ItemStatus::InProgress);
|
||||
let logged: LoggedItem = (&original).into();
|
||||
let json = serde_json::to_string(&logged).unwrap();
|
||||
let parsed: LoggedItem = serde_json::from_str(&json).unwrap();
|
||||
@@ -336,11 +377,17 @@ mod tests {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
call_index,
|
||||
execution_id,
|
||||
status,
|
||||
..
|
||||
} => {
|
||||
assert_eq!(call_id, "call_42");
|
||||
assert_eq!(name, "get_weather");
|
||||
assert_eq!(arguments, r#"{"city":"Tokyo"}"#);
|
||||
assert_eq!(call_index, Some(3));
|
||||
assert_eq!(execution_id.as_deref(), Some("batch:call_42"));
|
||||
assert_eq!(status, Some(ItemStatus::InProgress));
|
||||
}
|
||||
other => panic!("unexpected variant: {other:?}"),
|
||||
}
|
||||
|
||||
@@ -356,6 +356,7 @@ fn project_item(item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
|
||||
call_id,
|
||||
name,
|
||||
arguments,
|
||||
..
|
||||
} => Some(SessionSnapshotEntryData::ToolCall {
|
||||
call_id: call_id.clone(),
|
||||
name: name.clone(),
|
||||
@@ -385,8 +386,8 @@ fn project_item(item: &LoggedItem) -> Option<SessionSnapshotEntryData> {
|
||||
})
|
||||
.collect(),
|
||||
}),
|
||||
// Hidden model reasoning is never observable.
|
||||
LoggedItem::Reasoning { .. } => None,
|
||||
// Internal response grouping and hidden model reasoning are never observable.
|
||||
LoggedItem::AssistantResponseBoundary { .. } | LoggedItem::Reasoning { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -522,14 +522,29 @@ fn working_directory_text(worker: &BackendWorkerSummary) -> String {
|
||||
}
|
||||
let attachment = &worker.workdir_attachments[0];
|
||||
let directory = &attachment.working_directory;
|
||||
let source_label = match &directory.source {
|
||||
BackendWorkingDirectorySource::Repository { repository_key } => repository_key.as_str(),
|
||||
BackendWorkingDirectorySource::ExternalGrant { .. } => "external",
|
||||
let (source_label, permission_label) = match &directory.source {
|
||||
BackendWorkingDirectorySource::Repository { repository_key } => {
|
||||
(repository_key.as_str(), String::new())
|
||||
}
|
||||
BackendWorkingDirectorySource::ExternalGrant { .. } => {
|
||||
let permissions = &attachment.effective_permissions;
|
||||
let mut categories = Vec::new();
|
||||
if permissions.read {
|
||||
categories.push("READ");
|
||||
}
|
||||
if permissions.write {
|
||||
categories.push("WRITE");
|
||||
}
|
||||
if permissions.command {
|
||||
categories.push("COMMAND");
|
||||
}
|
||||
("external", format!("・{}", categories.join("・")))
|
||||
}
|
||||
};
|
||||
let label = directory.display_name.as_deref().unwrap_or(source_label);
|
||||
format!(
|
||||
"wd:{}:{}・{}",
|
||||
attachment.alias, label, directory.working_directory_id
|
||||
"wd:{}:{}{}・{}",
|
||||
attachment.alias, label, permission_label, directory.working_directory_id
|
||||
)
|
||||
}
|
||||
|
||||
@@ -826,6 +841,7 @@ mod tests {
|
||||
worker.workdir_attachments = vec![
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"alias": "checkout",
|
||||
"effective_permissions": {"read": true, "write": true, "command": true},
|
||||
"working_directory": {
|
||||
"working_directory_id": "001a06a9f0202000000",
|
||||
"display_name": "Checkout",
|
||||
@@ -871,6 +887,7 @@ mod tests {
|
||||
worker.workdir_attachments = vec![
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"alias": "checkout",
|
||||
"effective_permissions": {"read": true, "write": true, "command": true},
|
||||
"working_directory": {
|
||||
"working_directory_id": "workdir-1",
|
||||
"source": {"kind": "repository", "repository_key": "main"},
|
||||
@@ -901,6 +918,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_row_shows_external_attachment_effective_permissions() {
|
||||
let mut worker = worker("runtime-a", "worker-a", None);
|
||||
worker.workdir_attachments = vec![
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"alias": "sessions",
|
||||
"effective_permissions": {"read": false, "write": false, "command": true},
|
||||
"working_directory": {
|
||||
"working_directory_id": "external-1",
|
||||
"display_name": "Session data",
|
||||
"source": {
|
||||
"kind": "external_grant",
|
||||
"grant_id": "grant-1",
|
||||
"grant_permissions": {"read": true, "write": false, "command": true}
|
||||
},
|
||||
"materializer_kind": "client_hosted_external",
|
||||
"status": "active"
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
working_directory_text(&worker),
|
||||
"wd:sessions:Session data・COMMAND・external-1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picker_title_uses_backend_worker_wording() {
|
||||
let target = BackendRuntimeListTarget::new(
|
||||
|
||||
@@ -11,6 +11,7 @@ http-client = ["dep:reqwest"]
|
||||
[dependencies]
|
||||
async-trait.workspace = true
|
||||
fs-operation.workspace = true
|
||||
libc.workspace = true
|
||||
manifest.workspace = true
|
||||
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
|
||||
schemars.workspace = true
|
||||
|
||||
+157
-22
@@ -26,6 +26,17 @@ pub const MAX_EXTERNAL_RESULT_ITEMS: usize = 10_000;
|
||||
const MAX_EXTERNAL_GREP_CONTEXT: usize = 100;
|
||||
const MAX_EXTERNAL_SCOPE_RULES: usize = 64;
|
||||
pub const MAX_EXTERNAL_WRITE_BYTES: usize = 512 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_BYTES: usize = 64 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_OUTPUT_BYTES: usize = 512 * 1024;
|
||||
pub const MAX_EXTERNAL_COMMAND_TIMEOUT_SECS: u64 = 600;
|
||||
const MAX_EXTERNAL_COMMAND_CURSOR: usize = 1_000_000;
|
||||
|
||||
fn valid_command_handle(handle: &crate::CommandHandle) -> bool {
|
||||
!handle.0.is_empty()
|
||||
&& handle.0.len() <= MAX_PROTOCOL_ID_BYTES
|
||||
&& handle.0.trim() == handle.0
|
||||
&& !handle.0.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Fail-closed wire version for the External Workdir provider protocol.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
@@ -123,10 +134,9 @@ fn is_root_relative(path: &WorkdirPath) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate the filesystem-only subset shared by read-only and read-write
|
||||
/// External Workdirs. Capability enforcement remains a separate grant/session
|
||||
/// boundary; command operations never enter the provider protocol.
|
||||
fn validate_filesystem_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
|
||||
/// Validate the bounded operation subset available to External Workdirs.
|
||||
/// Capability enforcement remains a separate grant/session boundary.
|
||||
fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<(), String> {
|
||||
let path_is_valid = match operation {
|
||||
WorkdirSessionOperation::AuthorizeScope(request) => {
|
||||
is_root_relative(&request.path)
|
||||
@@ -182,17 +192,34 @@ fn validate_filesystem_operation(operation: &WorkdirSessionOperation) -> Result<
|
||||
.checked_add(request.new_string.len())
|
||||
.is_some_and(|size| size <= MAX_EXTERNAL_WRITE_BYTES)
|
||||
}
|
||||
WorkdirSessionOperation::CommandStart(_)
|
||||
| WorkdirSessionOperation::CommandStatus(_)
|
||||
| WorkdirSessionOperation::CommandOutput(_)
|
||||
| WorkdirSessionOperation::CommandCancel(_) => {
|
||||
return Err("command operations are not available to an External Workdir".to_string());
|
||||
WorkdirSessionOperation::CommandStart(request) => {
|
||||
!request.command.is_empty()
|
||||
&& request.command.len() <= MAX_EXTERNAL_COMMAND_BYTES
|
||||
&& (1..=MAX_EXTERNAL_COMMAND_TIMEOUT_SECS).contains(&request.timeout_secs)
|
||||
&& (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.output_limit)
|
||||
&& is_root_relative(&request.cwd)
|
||||
&& request.spill_dir.is_none()
|
||||
&& request.tool_call_id.as_ref().is_none_or(|tool_call_id| {
|
||||
!tool_call_id.is_empty()
|
||||
&& tool_call_id.len() <= MAX_PROTOCOL_ID_BYTES
|
||||
&& !tool_call_id.chars().any(char::is_control)
|
||||
})
|
||||
}
|
||||
WorkdirSessionOperation::CommandStatus(handle)
|
||||
| WorkdirSessionOperation::CommandCancel(handle) => valid_command_handle(handle),
|
||||
WorkdirSessionOperation::CommandOutput(request) => {
|
||||
valid_command_handle(&request.handle)
|
||||
&& request.cursor <= MAX_EXTERNAL_COMMAND_CURSOR
|
||||
&& (1..=MAX_EXTERNAL_COMMAND_OUTPUT_BYTES).contains(&request.limit)
|
||||
}
|
||||
};
|
||||
if path_is_valid {
|
||||
Ok(())
|
||||
} else {
|
||||
Err("External Workdir operation paths must be root-relative".to_string())
|
||||
Err(
|
||||
"External Workdir operation exceeds protocol bounds or uses an invalid path"
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -215,7 +242,7 @@ impl TryFrom<WorkdirSessionOperation> for ExternalWorkdirOperation {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(operation: WorkdirSessionOperation) -> Result<Self, Self::Error> {
|
||||
validate_filesystem_operation(&operation)?;
|
||||
validate_external_operation(&operation)?;
|
||||
Ok(Self(operation))
|
||||
}
|
||||
}
|
||||
@@ -239,7 +266,7 @@ fn result_paths_fit<'a>(paths: impl IntoIterator<Item = &'a WorkdirPath>) -> boo
|
||||
.is_some_and(|retained| retained <= fs_operation::MAX_RESULT_PATH_BYTES)
|
||||
}
|
||||
|
||||
fn validate_filesystem_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||
fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<(), String> {
|
||||
let result_is_valid = match result {
|
||||
WorkdirSessionOperationResult::AuthorizeScope
|
||||
| WorkdirSessionOperationResult::ScopeRulesOverlap { .. } => true,
|
||||
@@ -273,14 +300,16 @@ fn validate_filesystem_result(result: &WorkdirSessionOperationResult) -> Result<
|
||||
result.bytes_written <= MAX_EXTERNAL_WRITE_BYTES
|
||||
&& result.replacements <= MAX_EXTERNAL_RESULT_ITEMS
|
||||
}
|
||||
WorkdirSessionOperationResult::CommandStart(_)
|
||||
| WorkdirSessionOperationResult::CommandStatus(_)
|
||||
| WorkdirSessionOperationResult::CommandOutput(_)
|
||||
| WorkdirSessionOperationResult::CommandCancel => {
|
||||
return Err(
|
||||
"command operation results are not available to an External Workdir".to_string(),
|
||||
);
|
||||
WorkdirSessionOperationResult::CommandStart(handle) => valid_command_handle(handle),
|
||||
WorkdirSessionOperationResult::CommandStatus(_) => true,
|
||||
WorkdirSessionOperationResult::CommandOutput(output) => {
|
||||
output.content.len() <= MAX_EXTERNAL_COMMAND_OUTPUT_BYTES
|
||||
&& output
|
||||
.next_cursor
|
||||
.is_none_or(|cursor| cursor <= MAX_EXTERNAL_COMMAND_CURSOR)
|
||||
&& output.output_path.is_none()
|
||||
}
|
||||
WorkdirSessionOperationResult::CommandCancel => true,
|
||||
};
|
||||
if result_is_valid {
|
||||
Ok(())
|
||||
@@ -308,7 +337,7 @@ impl TryFrom<WorkdirSessionOperationResult> for ExternalWorkdirOperationResult {
|
||||
type Error = String;
|
||||
|
||||
fn try_from(result: WorkdirSessionOperationResult) -> Result<Self, Self::Error> {
|
||||
validate_filesystem_result(&result)?;
|
||||
validate_external_result(&result)?;
|
||||
Ok(Self(result))
|
||||
}
|
||||
}
|
||||
@@ -323,6 +352,27 @@ impl<'de> Deserialize<'de> for ExternalWorkdirOperationResult {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ExternalWorkdirOperationError {
|
||||
code: crate::http::WorkdirTransportErrorCode,
|
||||
}
|
||||
|
||||
impl ExternalWorkdirOperationError {
|
||||
/// Collapse provider failures to a closed error code. Provider-authored
|
||||
/// diagnostics never cross the External Workdir trust boundary.
|
||||
pub fn from_transport_error(error: WorkdirTransportError) -> Self {
|
||||
Self { code: error.code }
|
||||
}
|
||||
|
||||
pub fn into_transport_error(self) -> WorkdirTransportError {
|
||||
WorkdirTransportError {
|
||||
code: self.code,
|
||||
message: format!("External Workdir provider reported {}", self.code.as_str()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "outcome", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum ExternalWorkdirOperationOutcome {
|
||||
@@ -330,7 +380,7 @@ pub enum ExternalWorkdirOperationOutcome {
|
||||
result: ExternalWorkdirOperationResult,
|
||||
},
|
||||
Failed {
|
||||
error: WorkdirTransportError,
|
||||
error: ExternalWorkdirOperationError,
|
||||
},
|
||||
Cancelled,
|
||||
}
|
||||
@@ -528,6 +578,39 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_failures_are_closed_and_drop_provider_authored_diagnostics() {
|
||||
let error = ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||
code: crate::http::WorkdirTransportErrorCode::Internal,
|
||||
message: "/home/operator/private provider-secret\ncontrol".to_string(),
|
||||
});
|
||||
let frame = ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::OperationResult {
|
||||
generation: 1,
|
||||
operation_id: ExternalWorkdirOperationId::new("operation-failed").unwrap(),
|
||||
outcome: ExternalWorkdirOperationOutcome::Failed {
|
||||
error: error.clone(),
|
||||
},
|
||||
},
|
||||
);
|
||||
let value = serde_json::to_value(&frame).unwrap();
|
||||
assert_no_provider_authority(&value);
|
||||
assert!(value.to_string().len() < 512);
|
||||
assert_eq!(
|
||||
error.into_transport_error().message,
|
||||
"External Workdir provider reported internal"
|
||||
);
|
||||
|
||||
let provider_message = serde_json::to_string(&frame).unwrap().replace(
|
||||
r#""code":"internal""#,
|
||||
r#""code":"internal","message":"provider-secret""#,
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_str::<ExternalWorkdirProviderFrame>(&provider_message).is_err(),
|
||||
"provider-authored diagnostics must not be accepted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() {
|
||||
let future = r#"{
|
||||
@@ -544,7 +627,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filesystem_protocol_rejects_host_paths_and_commands_but_allows_bounded_mutations() {
|
||||
fn protocol_rejects_host_paths_and_unbounded_commands_but_allows_bounded_operations() {
|
||||
let absolute = WorkdirSessionOperation::Read(ReadRequest {
|
||||
path: WorkdirPath::new_scoped("/home/operator/private/session.log").unwrap(),
|
||||
offset: 0,
|
||||
@@ -563,6 +646,29 @@ mod tests {
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(command).is_err());
|
||||
|
||||
let command = WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||
command: "printf bounded".to_string(),
|
||||
timeout_secs: 30,
|
||||
output_limit: 12 * 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: Some("tool-call-1".to_string()),
|
||||
});
|
||||
assert!(ExternalWorkdirOperation::try_from(command).is_ok());
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::CommandStart(
|
||||
CommandRequest {
|
||||
command: "x".repeat(MAX_EXTERNAL_COMMAND_BYTES + 1),
|
||||
timeout_secs: 30,
|
||||
output_limit: 12 * 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
}
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(
|
||||
ExternalWorkdirOperation::try_from(WorkdirSessionOperation::Write(WriteRequest {
|
||||
path: WorkdirPath::new("created.txt").unwrap(),
|
||||
@@ -589,6 +695,35 @@ mod tests {
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
|
||||
assert!(
|
||||
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
||||
crate::CommandOutput {
|
||||
status: crate::CommandStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
content: "bounded".to_string(),
|
||||
next_cursor: None,
|
||||
truncated: false,
|
||||
output_path: None,
|
||||
}
|
||||
))
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
ExternalWorkdirOperationResult::try_from(WorkdirSessionOperationResult::CommandOutput(
|
||||
crate::CommandOutput {
|
||||
status: crate::CommandStatus::Completed,
|
||||
exit_code: Some(0),
|
||||
timed_out: false,
|
||||
content: "bounded".to_string(),
|
||||
next_cursor: None,
|
||||
truncated: true,
|
||||
output_path: Some("/provider/private/output".into()),
|
||||
}
|
||||
))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -2,10 +2,10 @@ use crate::http::{
|
||||
WorkdirSessionOperation, WorkdirSessionOperationResult, dispatch_workdir_session_operation,
|
||||
};
|
||||
use crate::{
|
||||
BoundedReadLimits, CommandRequest, EditRequest, ExternalWorkdirRoot, GlobRequest,
|
||||
GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir, WorkdirError,
|
||||
WorkdirPath, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability,
|
||||
WriteRequest,
|
||||
BoundedReadLimits, CommandOutputRequest, CommandRequest, EditRequest, ExternalWorkdirRoot,
|
||||
GlobRequest, GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, Workdir,
|
||||
WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities,
|
||||
WorkdirSessionCapability, WriteRequest,
|
||||
};
|
||||
|
||||
fn external_session(root: &tempfile::TempDir, limits: BoundedReadLimits) -> LocalWorkdirSession {
|
||||
@@ -162,6 +162,73 @@ async fn external_local_provider_read_write_is_filesystem_only() {
|
||||
assert!(!root.path().join("command-ran").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_command_permission_is_explicit_and_independent_from_file_access() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
std::fs::write(root.path().join("item.txt"), "private").unwrap();
|
||||
let pinned = ExternalWorkdirRoot::pin(root.path()).unwrap();
|
||||
let session = LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
Workdir::new("external-workdir-command"),
|
||||
pinned,
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
WorkdirSessionCapabilities::COMMAND_ONLY,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
session.capabilities(),
|
||||
WorkdirSessionCapabilities::COMMAND_ONLY
|
||||
);
|
||||
assert!(matches!(
|
||||
session
|
||||
.read(ReadRequest {
|
||||
path: WorkdirPath::new("item.txt").unwrap(),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
})
|
||||
.await,
|
||||
Err(WorkdirError::Unsupported(WorkdirSessionCapability::Read))
|
||||
));
|
||||
|
||||
let handle = session
|
||||
.start_command(CommandRequest {
|
||||
command: "printf command-ok".to_string(),
|
||||
timeout_secs: 5,
|
||||
output_limit: 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let output = session
|
||||
.command_output(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.content, "command-ok");
|
||||
assert_eq!(output.exit_code, Some(0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_write_category_never_implicitly_enables_read() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let pinned = ExternalWorkdirRoot::pin(root.path()).unwrap();
|
||||
let error = LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
Workdir::new("external-workdir-invalid-write"),
|
||||
pinned,
|
||||
BoundedReadLimits::new(4096, 1024).unwrap(),
|
||||
WorkdirSessionCapabilities::WRITE_ONLY,
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(error.to_string().contains("WRITE requires READ"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_dispatcher_preserves_operation_result_pairing_and_capabilities() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -134,6 +134,16 @@ impl WorkdirSessionCapabilities {
|
||||
bits: Self::READ | Self::GLOB | Self::GREP,
|
||||
};
|
||||
|
||||
/// Operator-facing WRITE category without implicitly granting READ or COMMAND.
|
||||
pub const WRITE_ONLY: Self = Self {
|
||||
bits: Self::WRITE | Self::EDIT,
|
||||
};
|
||||
|
||||
/// Operator-facing COMMAND category without implicitly granting file access.
|
||||
pub const COMMAND_ONLY: Self = Self {
|
||||
bits: Self::COMMAND,
|
||||
};
|
||||
|
||||
/// Filesystem read/write authority without process execution.
|
||||
///
|
||||
/// External Workdirs use this capability set so operator-approved file
|
||||
@@ -391,3 +401,23 @@ impl From<fs_operation::FsError> for WorkdirError {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod capability_tests {
|
||||
use super::*;
|
||||
|
||||
fn bits(capabilities: WorkdirSessionCapabilities) -> u8 {
|
||||
serde_json::to_value(capabilities).unwrap()["bits"]
|
||||
.as_u64()
|
||||
.unwrap() as u8
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_categories_preserve_operation_bit_values() {
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::READ_ONLY), 25);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::WRITE_ONLY), 6);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::READ_WRITE), 31);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::COMMAND_ONLY), 32);
|
||||
assert_eq!(bits(WorkdirSessionCapabilities::ALL), 63);
|
||||
}
|
||||
}
|
||||
|
||||
+138
-8
@@ -11,6 +11,8 @@
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::fmt::Debug;
|
||||
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::CommandExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
@@ -762,6 +764,42 @@ impl LocalWorkdirSession {
|
||||
Self::external_read_write_pinned(workdir, root, read_limits)
|
||||
}
|
||||
|
||||
/// Construct an External provider session with an exact operator-approved
|
||||
/// READ/WRITE/COMMAND category set. WRITE requires READ so callers can
|
||||
/// satisfy the read-before-write contract; COMMAND remains independent.
|
||||
pub fn external_with_capabilities_pinned(
|
||||
workdir: Workdir,
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
capabilities: WorkdirSessionCapabilities,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
let read = WorkdirSessionCapabilities::READ_ONLY;
|
||||
let read_write = WorkdirSessionCapabilities::READ_WRITE;
|
||||
let command = WorkdirSessionCapabilities::COMMAND_ONLY;
|
||||
if ![
|
||||
read,
|
||||
read_write,
|
||||
command,
|
||||
read.union(command),
|
||||
read_write.union(command),
|
||||
]
|
||||
.contains(&capabilities)
|
||||
{
|
||||
return Err(WorkdirError::InvalidArgument(
|
||||
"External Workdir capabilities must contain at least one of READ or COMMAND, and WRITE requires READ"
|
||||
.to_string(),
|
||||
));
|
||||
}
|
||||
let permission = if capabilities.supports(WorkdirSessionCapability::Write) {
|
||||
Permission::Write
|
||||
} else {
|
||||
// Command execution still needs provider-internal read access to
|
||||
// validate and enter its cwd. This scope is not an exposed READ grant.
|
||||
Permission::Read
|
||||
};
|
||||
Self::external_pinned(workdir, pinned, read_limits, permission, capabilities)
|
||||
}
|
||||
|
||||
/// Construct a read-only provider session from a root pinned before remote
|
||||
/// grant creation.
|
||||
pub fn external_read_only_pinned(
|
||||
@@ -769,11 +807,10 @@ impl LocalWorkdirSession {
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
Self::external_pinned(
|
||||
Self::external_with_capabilities_pinned(
|
||||
workdir,
|
||||
pinned,
|
||||
read_limits,
|
||||
Permission::Read,
|
||||
WorkdirSessionCapabilities::READ_ONLY,
|
||||
)
|
||||
}
|
||||
@@ -785,11 +822,10 @@ impl LocalWorkdirSession {
|
||||
pinned: ExternalWorkdirRoot,
|
||||
read_limits: BoundedReadLimits,
|
||||
) -> Result<Self, WorkdirError> {
|
||||
Self::external_pinned(
|
||||
Self::external_with_capabilities_pinned(
|
||||
workdir,
|
||||
pinned,
|
||||
read_limits,
|
||||
Permission::Write,
|
||||
WorkdirSessionCapabilities::READ_WRITE,
|
||||
)
|
||||
}
|
||||
@@ -1556,6 +1592,45 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CommandProcessGroupGuard {
|
||||
process_group_id: Option<u32>,
|
||||
}
|
||||
|
||||
impl CommandProcessGroupGuard {
|
||||
fn new(process_group_id: Option<u32>) -> Self {
|
||||
Self { process_group_id }
|
||||
}
|
||||
|
||||
fn terminate(&mut self) {
|
||||
let Some(process_group_id) = self.process_group_id.take() else {
|
||||
return;
|
||||
};
|
||||
terminate_process_group(process_group_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CommandProcessGroupGuard {
|
||||
fn drop(&mut self) {
|
||||
self.terminate();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn terminate_process_group(process_group_id: u32) {
|
||||
let Ok(process_group_id) = i32::try_from(process_group_id) else {
|
||||
return;
|
||||
};
|
||||
// The command is spawned as its own process-group leader below. A negative
|
||||
// pid addresses that exact group, including descendants that outlive bash.
|
||||
unsafe {
|
||||
libc::kill(-process_group_id, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn terminate_process_group(_process_group_id: u32) {}
|
||||
|
||||
async fn run_command(
|
||||
cwd: PathBuf,
|
||||
request: CommandRequest,
|
||||
@@ -1574,7 +1649,8 @@ async fn run_command(
|
||||
.map_err(|error| WorkdirError::io(&stderr_path, error))?;
|
||||
|
||||
telemetry.started(&command_id, request.tool_call_id.clone());
|
||||
let mut child = match Command::new("bash")
|
||||
let mut command = Command::new("bash");
|
||||
command
|
||||
.arg("-c")
|
||||
.arg(&request.command)
|
||||
.current_dir(&cwd)
|
||||
@@ -1582,15 +1658,17 @@ async fn run_command(
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::from(stdout_file))
|
||||
.stderr(Stdio::from(stderr_file))
|
||||
.kill_on_drop(true)
|
||||
.spawn()
|
||||
{
|
||||
.kill_on_drop(true);
|
||||
#[cfg(unix)]
|
||||
command.as_std_mut().process_group(0);
|
||||
let mut child = match command.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(error) => {
|
||||
telemetry.terminal(&command_id, CommandStatus::Failed, None);
|
||||
return Err(WorkdirError::io(&cwd, error));
|
||||
}
|
||||
};
|
||||
let mut process_group = CommandProcessGroupGuard::new(child.id());
|
||||
|
||||
let mut stdout_reader =
|
||||
std::fs::File::open(&stdout_path).map_err(|error| WorkdirError::io(&stdout_path, error))?;
|
||||
@@ -1609,18 +1687,21 @@ async fn run_command(
|
||||
tokio::select! {
|
||||
exit = child.wait() => {
|
||||
let exit = exit.map_err(|error| WorkdirError::io(&cwd, error))?;
|
||||
process_group.terminate();
|
||||
break (
|
||||
if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed },
|
||||
exit.code(),
|
||||
);
|
||||
}
|
||||
_ = &mut timeout => {
|
||||
process_group.terminate();
|
||||
let _ = child.start_kill();
|
||||
let exit_code = child.wait().await.ok().and_then(|status| status.code());
|
||||
break (CommandStatus::TimedOut, exit_code);
|
||||
}
|
||||
changed = cancel.changed() => {
|
||||
if changed.is_err() || *cancel.borrow() {
|
||||
process_group.terminate();
|
||||
let _ = child.start_kill();
|
||||
let exit_code = child.wait().await.ok().and_then(|status| status.code());
|
||||
break (CommandStatus::Cancelled, exit_code);
|
||||
@@ -3473,6 +3554,55 @@ mod tests {
|
||||
assert!(released.load(Ordering::Acquire));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn cancellation_kills_command_process_group_descendants() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let workdir = make_fs(&dir);
|
||||
let handle = WorkdirSession::start_command(
|
||||
&workdir,
|
||||
CommandRequest {
|
||||
command: "(sleep 0.4; echo leaked > child-marker) & echo ready > child-ready; wait"
|
||||
.into(),
|
||||
timeout_secs: 60,
|
||||
output_limit: 1024,
|
||||
cwd: WorkdirPath::root(),
|
||||
spill_dir: None,
|
||||
tool_call_id: None,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..100 {
|
||||
if dir.path().join("child-ready").exists() {
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(5)).await;
|
||||
}
|
||||
assert!(dir.path().join("child-ready").exists());
|
||||
|
||||
WorkdirSession::cancel_command(&workdir, handle.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let output = WorkdirSession::command_output(
|
||||
&workdir,
|
||||
CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 1024,
|
||||
wait: true,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.status, CommandStatus::Cancelled);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
assert!(
|
||||
!dir.path().join("child-marker").exists(),
|
||||
"cancelled command descendant survived its process group"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn provider_cancels_active_command() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -100,11 +100,24 @@ pub struct RuntimeWorkingDirectorySummary {
|
||||
pub occupied_by: Option<WorkingDirectoryOccupancy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkdirPermissionSummary {
|
||||
pub read: bool,
|
||||
pub write: bool,
|
||||
pub command: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum WorkingDirectorySource {
|
||||
Repository { repository_key: String },
|
||||
ExternalGrant { grant_id: String },
|
||||
Repository {
|
||||
repository_key: String,
|
||||
},
|
||||
ExternalGrant {
|
||||
grant_id: String,
|
||||
grant_permissions: WorkdirPermissionSummary,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
|
||||
|
||||
@@ -636,7 +636,7 @@ mod tests {
|
||||
use agen::llm_client::error::ClientError;
|
||||
use agen::llm_client::event::{
|
||||
BlockDelta, BlockMetadata, BlockStart, BlockStop, BlockType, DeltaContent,
|
||||
Event as LlmEvent, StopReason,
|
||||
Event as LlmEvent, ResponseStatus, StatusEvent, StopReason,
|
||||
};
|
||||
use agen::llm_client::types::Request;
|
||||
use agen::tool::ToolExecutionContext;
|
||||
@@ -906,6 +906,9 @@ mod tests {
|
||||
reasoning: None,
|
||||
stop_reason: Some(StopReason::ToolUse),
|
||||
})),
|
||||
Ok(LlmEvent::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})),
|
||||
])))
|
||||
} else {
|
||||
Ok(Box::pin(stream::iter(vec![
|
||||
@@ -924,6 +927,9 @@ mod tests {
|
||||
reasoning: None,
|
||||
stop_reason: Some(StopReason::EndTurn),
|
||||
})),
|
||||
Ok(LlmEvent::Status(StatusEvent {
|
||||
status: ResponseStatus::Completed,
|
||||
})),
|
||||
])))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,6 +363,10 @@ impl WorkerInterceptor {
|
||||
|
||||
#[async_trait]
|
||||
impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
||||
fn supports_early_tool_dispatch(&self) -> bool {
|
||||
self.registry.on_turn_end.is_empty()
|
||||
}
|
||||
|
||||
async fn on_prompt_submit(
|
||||
&self,
|
||||
context: PromptSubmitContext<'_, SessionHistoryMetadata>,
|
||||
|
||||
@@ -415,7 +415,7 @@ impl SessionCapture {
|
||||
search_text: text,
|
||||
});
|
||||
}
|
||||
Item::Reasoning { .. } => {}
|
||||
Item::AssistantResponseBoundary { .. } | Item::Reasoning { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,6 +713,9 @@ fn render_item(
|
||||
),
|
||||
}
|
||||
}
|
||||
Item::AssistantResponseBoundary { .. } => {
|
||||
format!("[{} Assistant response boundary omitted]", entry.id)
|
||||
}
|
||||
Item::Reasoning { .. } => format!("[{} Reasoning omitted]", entry.id),
|
||||
};
|
||||
truncate_chars(&text, max_bytes)
|
||||
|
||||
@@ -1371,6 +1371,9 @@ mod tests {
|
||||
call_id: format!("call-{index}"),
|
||||
name: name.to_string(),
|
||||
arguments: "{}".to_string(),
|
||||
call_index: None,
|
||||
execution_id: None,
|
||||
status: None,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
@@ -965,6 +965,7 @@ fn manifest_to_reusable_config(manifest: &WorkerManifest) -> WorkerManifestConfi
|
||||
stop_sequences: (!manifest.engine.stop_sequences.is_empty())
|
||||
.then_some(manifest.engine.stop_sequences.clone()),
|
||||
reasoning: manifest.engine.reasoning.clone(),
|
||||
tool_call_dispatch: Some(manifest.engine.tool_call_dispatch),
|
||||
tool_output: ToolOutputLimitsPartial {
|
||||
default_max_bytes: Some(manifest.engine.tool_output.default_max_bytes),
|
||||
per_tool: manifest.engine.tool_output.per_tool.clone(),
|
||||
@@ -2226,7 +2227,8 @@ max_tokens = 3333
|
||||
Some("reviewer"),
|
||||
&[("reviewer", "reviewer.toml", REVIEWER_PROFILE)],
|
||||
);
|
||||
let parent = parent_manifest(&parent_root, Some(&parent_root.join("deny")));
|
||||
let mut parent = parent_manifest(&parent_root, Some(&parent_root.join("deny")));
|
||||
parent.engine.tool_call_dispatch = manifest::ToolCallDispatchMode::OnToolCallComplete;
|
||||
let scope = vec![abs_rule(&delegated, Permission::Read)];
|
||||
|
||||
let profile_config = child_config_from_profile(
|
||||
@@ -2248,6 +2250,10 @@ max_tokens = 3333
|
||||
SpawnProfileSelector::Inherit,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
inherit_config.engine.tool_call_dispatch,
|
||||
Some(manifest::ToolCallDispatchMode::OnToolCallComplete)
|
||||
);
|
||||
for config in [profile_config, inherit_config] {
|
||||
assert_eq!(config.scope.allow, scope);
|
||||
assert!(config.scope.deny.is_empty());
|
||||
|
||||
@@ -1264,7 +1264,10 @@ fn active_run_checkpoint_entry(
|
||||
fn is_ai_materialized_item(item: &Item) -> bool {
|
||||
match item {
|
||||
Item::Message { role, .. } => *role == Role::Assistant,
|
||||
Item::ToolCall { .. } | Item::ToolResult { .. } | Item::Reasoning { .. } => true,
|
||||
Item::AssistantResponseBoundary { .. }
|
||||
| Item::ToolCall { .. }
|
||||
| Item::ToolResult { .. }
|
||||
| Item::Reasoning { .. } => true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1302,7 +1305,11 @@ where
|
||||
let subject = worker_subject(annotation_writer.state.location().session_id);
|
||||
let origin = if item.is_tool_result() {
|
||||
WorkerHistoryProvenance::ToolOutput { worker: subject }
|
||||
} else if item.is_assistant_message() || item.is_tool_call() || item.is_reasoning() {
|
||||
} else if item.is_assistant_message()
|
||||
|| matches!(item, Item::AssistantResponseBoundary { .. })
|
||||
|| item.is_tool_call()
|
||||
|| item.is_reasoning()
|
||||
{
|
||||
WorkerHistoryProvenance::ModelOutput { worker: subject }
|
||||
} else {
|
||||
// Unknown user/system append paths fail closed. Trusted system
|
||||
@@ -7138,6 +7145,12 @@ pub fn apply_worker_manifest<C: LlmClient + 'static, A: Send + Sync>(
|
||||
) {
|
||||
worker.set_request_config(request_config_from_engine_manifest(wm));
|
||||
worker.set_max_turns(wm.max_turns.map(|n| n.get()));
|
||||
worker.set_tool_call_dispatch_mode(match wm.tool_call_dispatch {
|
||||
manifest::ToolCallDispatchMode::AfterResponse => agen::ToolCallDispatchMode::AfterResponse,
|
||||
manifest::ToolCallDispatchMode::OnToolCallComplete => {
|
||||
agen::ToolCallDispatchMode::OnToolCallComplete
|
||||
}
|
||||
});
|
||||
// Worker owns the lifecycle strategy for already-started tool operations.
|
||||
// The provider must first accept cooperative cancellation, then confirm a
|
||||
// terminal result before this bounded deadline; Agen handles only the
|
||||
@@ -7551,6 +7564,7 @@ fn write_overview_header(items: &[Item], out: &mut String) {
|
||||
for item in items {
|
||||
match item {
|
||||
Item::Message { .. } => messages += 1,
|
||||
Item::AssistantResponseBoundary { .. } => {}
|
||||
Item::ToolCall { .. } => tool_calls += 1,
|
||||
Item::ToolResult { .. } => tool_results += 1,
|
||||
Item::Reasoning { .. } => reasoning += 1,
|
||||
@@ -8721,6 +8735,7 @@ mod build_summary_prompt_tests {
|
||||
top_k: Some(40),
|
||||
stop_sequences: vec!["\n\n".into(), "</stop>".into()],
|
||||
reasoning: None,
|
||||
tool_call_dispatch: manifest::ToolCallDispatchMode::OnToolCallComplete,
|
||||
tool_output: manifest::ToolOutputLimits::default(),
|
||||
file_upload: manifest::FileUploadLimits::default(),
|
||||
};
|
||||
@@ -8732,6 +8747,13 @@ mod build_summary_prompt_tests {
|
||||
assert_eq!(config.top_p, Some(0.9));
|
||||
assert_eq!(config.top_k, Some(40));
|
||||
assert_eq!(config.stop_sequences, vec!["\n\n", "</stop>"]);
|
||||
|
||||
let mut engine = Engine::new(NoopClient);
|
||||
apply_worker_manifest(&mut engine, &manifest);
|
||||
assert_eq!(
|
||||
engine.tool_call_dispatch_mode(),
|
||||
agen::ToolCallDispatchMode::OnToolCallComplete
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -697,7 +697,10 @@ CREATE TABLE external_workdir_grants (
|
||||
workdir_id TEXT NOT NULL,
|
||||
provider_instance_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL CHECK (permissions IN ('read_only', 'read_write')),
|
||||
permissions TEXT NOT NULL CHECK (permissions IN (
|
||||
'read_only', 'read_write', 'command_only',
|
||||
'read_command', 'read_write_command'
|
||||
)),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
|
||||
+1872
-161
File diff suppressed because it is too large
Load Diff
@@ -21,7 +21,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 67;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 69;
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||
@@ -50,6 +50,10 @@ const TICKET_TARGETS_AND_WORKDIR_CAPABILITIES_MIGRATION_NAME: &str =
|
||||
"Ticket target collection and Workdir attachment capabilities";
|
||||
const EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME: &str =
|
||||
"External Workdir read-write access and optional expiry";
|
||||
const EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME: &str =
|
||||
"External Workdir explicit command permission";
|
||||
const WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME: &str =
|
||||
"External Workdir command attachment capabilities";
|
||||
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
||||
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
||||
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
||||
@@ -182,6 +186,16 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME,
|
||||
apply: migrate_external_workdir_access_and_optional_expiry_v66_to_v67,
|
||||
},
|
||||
Migration {
|
||||
version: 68,
|
||||
name: EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME,
|
||||
apply: migrate_external_workdir_command_permission_v67_to_v68,
|
||||
},
|
||||
Migration {
|
||||
version: 69,
|
||||
name: WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME,
|
||||
apply: migrate_workdir_command_attachment_capabilities_v68_to_v69,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -9131,37 +9145,52 @@ fn read_device_login_flow_record(
|
||||
})
|
||||
}
|
||||
|
||||
const WORKDIR_LINK_CAPABILITY_ENCODINGS: &[(workdir::WorkdirSessionCapabilities, &str)] = &[
|
||||
(workdir::WorkdirSessionCapabilities::ALL, "all"),
|
||||
(workdir::WorkdirSessionCapabilities::READ_ONLY, "read_only"),
|
||||
(
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE,
|
||||
"read_write",
|
||||
),
|
||||
(
|
||||
workdir::WorkdirSessionCapabilities::COMMAND_ONLY,
|
||||
"command_only",
|
||||
),
|
||||
(
|
||||
workdir::WorkdirSessionCapabilities::READ_ONLY
|
||||
.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY),
|
||||
"read_command",
|
||||
),
|
||||
];
|
||||
|
||||
fn encode_workdir_link_capabilities(
|
||||
capabilities: workdir::WorkdirSessionCapabilities,
|
||||
) -> Result<&'static str> {
|
||||
if capabilities == workdir::WorkdirSessionCapabilities::ALL {
|
||||
Ok("all")
|
||||
} else if capabilities == workdir::WorkdirSessionCapabilities::READ_ONLY {
|
||||
Ok("read_only")
|
||||
} else if capabilities == workdir::WorkdirSessionCapabilities::READ_WRITE {
|
||||
Ok("read_write")
|
||||
} else {
|
||||
Err(Error::InvalidInput(
|
||||
"Workdir attachment capabilities must be exactly all, read_only, or read_write"
|
||||
WORKDIR_LINK_CAPABILITY_ENCODINGS
|
||||
.iter()
|
||||
.find_map(|(candidate, encoded)| (*candidate == capabilities).then_some(*encoded))
|
||||
.ok_or_else(|| {
|
||||
Error::InvalidInput(
|
||||
"Workdir attachment capabilities must be exactly all, read_only, read_write, command_only, or read_command"
|
||||
.to_string(),
|
||||
))
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_workdir_link_capabilities(
|
||||
value: &str,
|
||||
column: usize,
|
||||
) -> rusqlite::Result<workdir::WorkdirSessionCapabilities> {
|
||||
match value {
|
||||
"all" => Ok(workdir::WorkdirSessionCapabilities::ALL),
|
||||
"read_only" => Ok(workdir::WorkdirSessionCapabilities::READ_ONLY),
|
||||
"read_write" => Ok(workdir::WorkdirSessionCapabilities::READ_WRITE),
|
||||
_ => Err(rusqlite::Error::FromSqlConversionFailure(
|
||||
WORKDIR_LINK_CAPABILITY_ENCODINGS
|
||||
.iter()
|
||||
.find_map(|(capabilities, encoded)| (*encoded == value).then_some(*capabilities))
|
||||
.ok_or_else(|| {
|
||||
rusqlite::Error::FromSqlConversionFailure(
|
||||
column,
|
||||
rusqlite::types::Type::Text,
|
||||
format!("invalid Worker Workdir link capabilities {value:?}").into(),
|
||||
)),
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_worker_workdir_link_record(
|
||||
@@ -12098,6 +12127,187 @@ fn migrate_external_workdir_access_and_optional_expiry_v66_to_v67(conn: &Connect
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_external_workdir_command_permission_v67_to_v68(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if current != 67 {
|
||||
return Err(Error::Store(format!(
|
||||
"expected schema version 67 before {EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME} migration, found {current}"
|
||||
)));
|
||||
}
|
||||
let foreign_keys_enabled =
|
||||
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0;
|
||||
if foreign_keys_enabled {
|
||||
conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
|
||||
}
|
||||
let result = (|| {
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE external_workdir_grants_v68 (
|
||||
grant_id TEXT NOT NULL,
|
||||
workspace_id TEXT NOT NULL,
|
||||
workdir_id TEXT NOT NULL,
|
||||
provider_instance_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
permissions TEXT NOT NULL CHECK (permissions IN (
|
||||
'read_only', 'read_write', 'command_only',
|
||||
'read_command', 'read_write_command'
|
||||
)),
|
||||
created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
generation INTEGER NOT NULL CHECK (generation > 0),
|
||||
status TEXT NOT NULL CHECK (status IN ('pending', 'online', 'offline', 'revoked', 'expired')),
|
||||
updated_at TEXT NOT NULL,
|
||||
cleanup_state TEXT NOT NULL DEFAULT 'not_required' CHECK (cleanup_state IN ('not_required', 'pending', 'retry_required', 'completed')),
|
||||
cleanup_error TEXT,
|
||||
cleanup_updated_at TEXT,
|
||||
PRIMARY KEY (workspace_id, grant_id),
|
||||
UNIQUE (workspace_id, workdir_id),
|
||||
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, workdir_id)
|
||||
REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE DEFERRABLE INITIALLY DEFERRED
|
||||
);
|
||||
INSERT INTO external_workdir_grants_v68 (
|
||||
grant_id, workspace_id, workdir_id, provider_instance_id, display_name,
|
||||
permissions, created_by, created_at, expires_at, generation, status, updated_at,
|
||||
cleanup_state, cleanup_error, cleanup_updated_at
|
||||
) SELECT grant_id, workspace_id, workdir_id, provider_instance_id, display_name,
|
||||
permissions, created_by, created_at, expires_at, generation, status, updated_at,
|
||||
cleanup_state, cleanup_error, cleanup_updated_at
|
||||
FROM external_workdir_grants;
|
||||
DROP TABLE external_workdir_grants;
|
||||
ALTER TABLE external_workdir_grants_v68 RENAME TO external_workdir_grants;
|
||||
CREATE INDEX idx_external_workdir_grants_status_expiry
|
||||
ON external_workdir_grants(workspace_id, status, expires_at);
|
||||
"#,
|
||||
)?;
|
||||
let violations =
|
||||
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})?;
|
||||
if violations != 0 {
|
||||
return Err(Error::Store(format!(
|
||||
"schema-68 migration left {violations} foreign-key violation(s)"
|
||||
)));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![68_i64, EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
let restore = if foreign_keys_enabled {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
match (result, restore) {
|
||||
(Err(error), _) => Err(error),
|
||||
(Ok(()), Err(error)) => Err(error),
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_workdir_command_attachment_capabilities_v68_to_v69(conn: &Connection) -> Result<()> {
|
||||
let current = current_schema_version(conn)?;
|
||||
if current != 68 {
|
||||
return Err(Error::Store(format!(
|
||||
"expected schema version 68 before {WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME} migration, found {current}"
|
||||
)));
|
||||
}
|
||||
let foreign_keys_enabled =
|
||||
conn.query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0))? != 0;
|
||||
if foreign_keys_enabled {
|
||||
conn.execute_batch("PRAGMA foreign_keys = OFF;")?;
|
||||
}
|
||||
let result = (|| {
|
||||
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
CREATE TABLE worker_workdir_links_v69 (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
worker_id TEXT NOT NULL,
|
||||
workdir_id TEXT NOT NULL,
|
||||
alias TEXT NOT NULL,
|
||||
capabilities TEXT NOT NULL CHECK (capabilities IN (
|
||||
'all', 'read_only', 'read_write', 'command_only', 'read_command'
|
||||
)),
|
||||
linked_at TEXT NOT NULL,
|
||||
unlinked_at TEXT,
|
||||
PRIMARY KEY (workspace_id, worker_id, workdir_id, alias),
|
||||
FOREIGN KEY (workspace_id, worker_id)
|
||||
REFERENCES worker_registry(workspace_id, worker_id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (workspace_id, workdir_id)
|
||||
REFERENCES workdir_registry(workspace_id, workdir_id) ON DELETE CASCADE
|
||||
);
|
||||
INSERT INTO worker_workdir_links_v69 (
|
||||
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
|
||||
linked_at, unlinked_at
|
||||
) SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
|
||||
linked_at, unlinked_at
|
||||
FROM worker_workdir_links;
|
||||
DROP TABLE worker_workdir_links;
|
||||
ALTER TABLE worker_workdir_links_v69 RENAME TO worker_workdir_links;
|
||||
CREATE UNIQUE INDEX worker_workdir_links_active_workdir_unique
|
||||
ON worker_workdir_links(workspace_id, workdir_id) WHERE unlinked_at IS NULL;
|
||||
CREATE UNIQUE INDEX worker_workdir_links_active_alias_unique
|
||||
ON worker_workdir_links(workspace_id, worker_id, alias) WHERE unlinked_at IS NULL;
|
||||
CREATE INDEX worker_workdir_links_workdir
|
||||
ON worker_workdir_links(workspace_id, workdir_id);
|
||||
CREATE TRIGGER workdir_attachment_insert_blocked_by_runtime_removal
|
||||
BEFORE INSERT ON worker_workdir_links FOR EACH ROW
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM runtime_removal_operations operation
|
||||
WHERE operation.runtime_id = NEW.runtime_id
|
||||
AND operation.state IN ('pending', 'cleanup_pending')
|
||||
)
|
||||
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||
CREATE TRIGGER workdir_attachment_update_blocked_by_runtime_removal
|
||||
BEFORE UPDATE ON worker_workdir_links FOR EACH ROW
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM runtime_removal_operations operation
|
||||
WHERE operation.runtime_id = NEW.runtime_id
|
||||
AND operation.state IN ('pending', 'cleanup_pending')
|
||||
)
|
||||
BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
|
||||
"#,
|
||||
)?;
|
||||
let violations =
|
||||
tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})?;
|
||||
if violations != 0 {
|
||||
return Err(Error::Store(format!(
|
||||
"schema-69 migration left {violations} foreign-key violation(s)"
|
||||
)));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||
params![
|
||||
69_i64,
|
||||
WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME
|
||||
],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
})();
|
||||
let restore = if foreign_keys_enabled {
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")
|
||||
.map_err(Error::from)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
match (result, restore) {
|
||||
(Err(error), _) => Err(error),
|
||||
(Ok(()), Err(error)) => Err(error),
|
||||
(Ok(()), Ok(())) => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
||||
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
||||
@@ -12160,7 +12370,9 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
ON typed_ticket_targets(workspace_id, repository_key, ticket_id);
|
||||
ALTER TABLE worker_workdir_links
|
||||
ADD COLUMN capabilities TEXT NOT NULL
|
||||
CHECK (capabilities IN ('all', 'read_only', 'read_write'));
|
||||
CHECK (capabilities IN (
|
||||
'all', 'read_only', 'read_write', 'command_only', 'read_command'
|
||||
));
|
||||
"#,
|
||||
)?;
|
||||
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
|
||||
@@ -12856,6 +13068,29 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_workdir_link_capability_codec_round_trips_only_canonical_sets() {
|
||||
for (capabilities, encoded) in WORKDIR_LINK_CAPABILITY_ENCODINGS {
|
||||
assert_eq!(
|
||||
encode_workdir_link_capabilities(*capabilities).unwrap(),
|
||||
*encoded
|
||||
);
|
||||
assert_eq!(
|
||||
decode_workdir_link_capabilities(encoded, 0).unwrap(),
|
||||
*capabilities
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
encode_workdir_link_capabilities(workdir::WorkdirSessionCapabilities::EMPTY).is_err()
|
||||
);
|
||||
assert!(
|
||||
encode_workdir_link_capabilities(workdir::WorkdirSessionCapabilities::WRITE_ONLY)
|
||||
.is_err()
|
||||
);
|
||||
assert!(decode_workdir_link_capabilities("write_only", 0).is_err());
|
||||
assert!(decode_workdir_link_capabilities("future_authority", 0).is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn external_workdir_grant_persists_typed_source_and_fences_generations() {
|
||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||
@@ -14233,6 +14468,14 @@ mod tests {
|
||||
version: 67,
|
||||
name: EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 68,
|
||||
name: EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
WorkspaceSchemaMigrationStep {
|
||||
version: 69,
|
||||
name: WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME.to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -14295,6 +14538,14 @@ mod tests {
|
||||
67,
|
||||
EXTERNAL_WORKDIR_ACCESS_AND_OPTIONAL_EXPIRY_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
(
|
||||
68,
|
||||
EXTERNAL_WORKDIR_COMMAND_PERMISSION_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
(
|
||||
69,
|
||||
WORKDIR_COMMAND_ATTACHMENT_CAPABILITIES_MIGRATION_NAME.to_string(),
|
||||
),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -14348,6 +14599,151 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v68_preserves_legacy_external_grants_without_command_authority() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("server.db");
|
||||
prepare_schema_v50(&path, Some("workspace-a"));
|
||||
let mut conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
for migration in MIGRATIONS
|
||||
.iter()
|
||||
.filter(|migration| migration.version <= 67)
|
||||
{
|
||||
(migration.apply)(&conn).unwrap();
|
||||
}
|
||||
let tx = conn.transaction().unwrap();
|
||||
tx.execute_batch(
|
||||
"INSERT INTO external_workdir_grants (
|
||||
grant_id, workspace_id, workdir_id, provider_instance_id, display_name,
|
||||
permissions, created_by, created_at, expires_at, generation, status, updated_at
|
||||
) VALUES
|
||||
('grant-ro', 'workspace-a', 'external-ro', 'provider-ro', 'Read only',
|
||||
'read_only', 'owner', '1', NULL, 1, 'pending', '1'),
|
||||
('grant-rw', 'workspace-a', 'external-rw', 'provider-rw', 'Read write',
|
||||
'read_write', 'owner', '1', NULL, 1, 'pending', '1');
|
||||
INSERT INTO workdir_registry (
|
||||
workspace_id, workdir_id, display_name, source_kind, runtime_id,
|
||||
repository_id, external_grant_id, creation_selector, creation_ref,
|
||||
materialization_status, cleanliness, created_at, updated_at
|
||||
) VALUES
|
||||
('workspace-a', 'external-ro', 'Read only', 'external_grant', NULL,
|
||||
NULL, 'grant-ro', NULL, NULL, 'pending', 'unknown', '1', '1'),
|
||||
('workspace-a', 'external-rw', 'Read write', 'external_grant', NULL,
|
||||
NULL, 'grant-rw', NULL, NULL, 'pending', 'unknown', '1', '1');",
|
||||
)
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
|
||||
migrate_external_workdir_command_permission_v67_to_v68(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 68);
|
||||
let permissions = conn
|
||||
.prepare("SELECT permissions FROM external_workdir_grants ORDER BY grant_id")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
assert_eq!(permissions, vec!["read_only", "read_write"]);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE external_workdir_grants SET permissions = 'read_command' WHERE grant_id = 'grant-ro'",
|
||||
[],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE external_workdir_grants SET permissions = 'write_only' WHERE grant_id = 'grant-rw'",
|
||||
[],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v69_preserves_legacy_attachment_capabilities_and_allows_command_subsets() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("server.db");
|
||||
prepare_schema_v50(&path, Some("workspace-a"));
|
||||
let mut conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
for migration in MIGRATIONS
|
||||
.iter()
|
||||
.filter(|migration| migration.version <= 68)
|
||||
{
|
||||
(migration.apply)(&conn).unwrap();
|
||||
}
|
||||
let tx = conn.transaction().unwrap();
|
||||
tx.execute_batch(
|
||||
r#"
|
||||
INSERT INTO worker_registry (
|
||||
workspace_id, runtime_id, worker_id, display_name, retention_state,
|
||||
created_at, updated_at
|
||||
) VALUES ('workspace-a', 'runtime-a', 'worker-a', 'Worker A', 'normal', '1', '1');
|
||||
INSERT INTO external_workdir_grants (
|
||||
grant_id, workspace_id, workdir_id, provider_instance_id, display_name,
|
||||
permissions, created_by, created_at, expires_at, generation, status, updated_at
|
||||
) VALUES
|
||||
('grant-ro', 'workspace-a', 'external-ro', 'provider-ro', 'Read only',
|
||||
'read_only', 'owner', '1', NULL, 1, 'pending', '1'),
|
||||
('grant-rw', 'workspace-a', 'external-rw', 'provider-rw', 'Read write',
|
||||
'read_write', 'owner', '1', NULL, 1, 'pending', '1');
|
||||
INSERT INTO workdir_registry (
|
||||
workspace_id, workdir_id, display_name, source_kind, runtime_id,
|
||||
repository_id, external_grant_id, creation_selector, creation_ref,
|
||||
materialization_status, cleanliness, created_at, updated_at
|
||||
) VALUES
|
||||
('workspace-a', 'external-ro', 'Read only', 'external_grant', NULL,
|
||||
NULL, 'grant-ro', NULL, NULL, 'pending', 'unknown', '1', '1'),
|
||||
('workspace-a', 'external-rw', 'Read write', 'external_grant', NULL,
|
||||
NULL, 'grant-rw', NULL, NULL, 'pending', 'unknown', '1', '1');
|
||||
INSERT INTO worker_workdir_links (
|
||||
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
|
||||
linked_at, unlinked_at
|
||||
) VALUES
|
||||
('workspace-a', 'runtime-a', 'worker-a', 'external-ro', 'readonly',
|
||||
'read_only', '1', '2'),
|
||||
('workspace-a', 'runtime-a', 'worker-a', 'external-rw', 'readwrite',
|
||||
'read_write', '1', '2');
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
tx.commit().unwrap();
|
||||
|
||||
migrate_workdir_command_attachment_capabilities_v68_to_v69(&conn).unwrap();
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 69);
|
||||
let capabilities = conn
|
||||
.prepare("SELECT capabilities FROM worker_workdir_links ORDER BY alias")
|
||||
.unwrap()
|
||||
.query_map([], |row| row.get::<_, String>(0))
|
||||
.unwrap()
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.unwrap();
|
||||
assert_eq!(capabilities, vec!["read_only", "read_write"]);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE worker_workdir_links SET capabilities = 'command_only' WHERE alias = 'readonly'",
|
||||
[],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE worker_workdir_links SET capabilities = 'read_command' WHERE alias = 'readwrite'",
|
||||
[],
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE worker_workdir_links SET capabilities = 'write_only' WHERE alias = 'readonly'",
|
||||
[],
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_resumes_from_a_valid_partially_applied_chain() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -14366,7 +14762,7 @@ mod tests {
|
||||
.map(|migration| migration.version)
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67
|
||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69
|
||||
]
|
||||
);
|
||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||
@@ -14375,7 +14771,7 @@ mod tests {
|
||||
current_schema_version(&conn).unwrap(),
|
||||
LATEST_SCHEMA_VERSION
|
||||
);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 18);
|
||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 20);
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||
|
||||
+107
-11
@@ -623,6 +623,7 @@ fn parse_workdir_args(
|
||||
let mut ttl = None;
|
||||
let mut read_only = false;
|
||||
let mut read_write = false;
|
||||
let mut explicit_permissions: Option<server_api::ExternalWorkdirPermissions> = None;
|
||||
let mut non_interactive = false;
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
@@ -666,6 +667,32 @@ fn parse_workdir_args(
|
||||
);
|
||||
index += 2;
|
||||
}
|
||||
"--permission" => {
|
||||
let category = required_option_value(args, index, "--permission")?;
|
||||
let permissions =
|
||||
explicit_permissions.get_or_insert(server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: false,
|
||||
});
|
||||
let enabled = match category.to_ascii_lowercase().as_str() {
|
||||
"read" => &mut permissions.read,
|
||||
"write" => &mut permissions.write,
|
||||
"command" => &mut permissions.command,
|
||||
_ => {
|
||||
return Err(ParseError(format!(
|
||||
"unknown --permission category `{category}`; expected read, write, or command"
|
||||
)));
|
||||
}
|
||||
};
|
||||
if *enabled {
|
||||
return Err(ParseError(format!(
|
||||
"--permission {category} may only be provided once"
|
||||
)));
|
||||
}
|
||||
*enabled = true;
|
||||
index += 2;
|
||||
}
|
||||
"--read-only" => {
|
||||
if read_only {
|
||||
return Err(ParseError(
|
||||
@@ -715,6 +742,21 @@ fn parse_workdir_args(
|
||||
"--read-only conflicts with --read-write".to_string(),
|
||||
));
|
||||
}
|
||||
if explicit_permissions.is_some() && (read_only || read_write) {
|
||||
return Err(ParseError(
|
||||
"--permission conflicts with legacy --read-only/--read-write".to_string(),
|
||||
));
|
||||
}
|
||||
let permissions = explicit_permissions.unwrap_or(server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: read_write,
|
||||
command: false,
|
||||
});
|
||||
if permissions.write && !permissions.read {
|
||||
return Err(ParseError(
|
||||
"--permission write requires --permission read for read-before-write".to_string(),
|
||||
));
|
||||
}
|
||||
if non_interactive && workspace_id.is_none() {
|
||||
return Err(ParseError(
|
||||
"--non-interactive requires --workspace-id".to_string(),
|
||||
@@ -728,11 +770,7 @@ fn parse_workdir_args(
|
||||
backend_url,
|
||||
display_name: display_name.unwrap_or_else(|| "External Workdir".to_string()),
|
||||
ttl,
|
||||
permission: if read_write {
|
||||
server_api::ExternalWorkdirPermission::ReadWrite
|
||||
} else {
|
||||
server_api::ExternalWorkdirPermission::ReadOnly
|
||||
},
|
||||
permissions,
|
||||
non_interactive,
|
||||
}))
|
||||
}
|
||||
@@ -1862,7 +1900,7 @@ Usage:
|
||||
yoi --backend <URL> [--workspace-id <ID>] panel
|
||||
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
|
||||
yoi [--backend <URL>] login [--no-wait]
|
||||
yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive]
|
||||
yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--permission <read|write|command>]... [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive]
|
||||
yoi <HOST_COMMAND> [OPTIONS]
|
||||
|
||||
Target selection:
|
||||
@@ -1908,7 +1946,7 @@ Options:
|
||||
|
||||
fn print_workdir_help() {
|
||||
println!(
|
||||
"yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, permission, and TTL, then explicitly confirm before any grant is created.\nRead-only is the default permission. --read-write enables the existing filesystem Write/Edit operations only; Bash, command, and exec remain disabled.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id <ID> Preselect a Workspace; required with --non-interactive\n --read-only Explicitly select the default read-only permission\n --read-write Allow filesystem Write/Edit operations (never commands)\n --ttl <TTL> Automatic expiry; omission is unlimited\n --display-name <NAME> Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n"
|
||||
"yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, READ/WRITE/COMMAND categories, and TTL, then explicitly confirm before any grant is created.\nREAD enables Read/Glob/Grep and related observation operations. WRITE enables Write/Edit and requires READ for read-before-write. COMMAND is independent, disabled by default, and runs with the sharing CLI user's host authority; it is not sandboxed.\nRepeat --permission to choose explicit categories in automation. Legacy --read-only and --read-write remain file-only aliases and conflict with --permission.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id <ID> Preselect a Workspace; required with --non-interactive\n --permission <CATEGORY> Enable read, write, or command; repeat per category\n --read-only Legacy alias for --permission read\n --read-write Legacy alias for read + write (commands remain disabled)\n --ttl <TTL> Automatic expiry; omission is unlimited\n --display-name <NAME> Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1989,12 +2027,43 @@ mod tests {
|
||||
assert_eq!(options.display_name, "session-analysis");
|
||||
assert_eq!(options.ttl, Some(Duration::from_secs(5_400)));
|
||||
assert_eq!(
|
||||
options.permission,
|
||||
server_api::ExternalWorkdirPermission::ReadWrite
|
||||
options.permissions,
|
||||
server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: true,
|
||||
command: false,
|
||||
}
|
||||
);
|
||||
assert!(options.non_interactive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_share_parses_independent_command_permission() {
|
||||
let args = vec![
|
||||
"share".to_string(),
|
||||
".".to_string(),
|
||||
"--permission".to_string(),
|
||||
"command".to_string(),
|
||||
"--non-interactive".to_string(),
|
||||
];
|
||||
let target = TargetSelection {
|
||||
backend_url: Some("https://backend.example".to_string()),
|
||||
workspace_id: Some("workspace-a".to_string()),
|
||||
..TargetSelection::default()
|
||||
};
|
||||
let Mode::WorkdirShare(options) = parse_workdir_args(&args, &target).unwrap() else {
|
||||
panic!("expected Workdir share mode");
|
||||
};
|
||||
assert_eq!(
|
||||
options.permissions,
|
||||
server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workdir_share_defaults_to_interactive_read_only_and_unlimited() {
|
||||
let args = vec!["share".to_string(), ".".to_string()];
|
||||
@@ -2009,8 +2078,8 @@ mod tests {
|
||||
assert_eq!(options.display_name, "External Workdir");
|
||||
assert_eq!(options.ttl, None);
|
||||
assert_eq!(
|
||||
options.permission,
|
||||
server_api::ExternalWorkdirPermission::ReadOnly
|
||||
options.permissions,
|
||||
server_api::ExternalWorkdirPermissions::default()
|
||||
);
|
||||
assert!(!options.non_interactive);
|
||||
}
|
||||
@@ -2048,6 +2117,33 @@ mod tests {
|
||||
.contains("conflicts")
|
||||
);
|
||||
|
||||
let legacy_conflict = vec![
|
||||
"share".to_string(),
|
||||
".".to_string(),
|
||||
"--read-write".to_string(),
|
||||
"--permission".to_string(),
|
||||
"command".to_string(),
|
||||
];
|
||||
assert!(
|
||||
parse_workdir_args(&legacy_conflict, &target)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("conflicts")
|
||||
);
|
||||
|
||||
let write_without_read = vec![
|
||||
"share".to_string(),
|
||||
".".to_string(),
|
||||
"--permission".to_string(),
|
||||
"write".to_string(),
|
||||
];
|
||||
assert!(
|
||||
parse_workdir_args(&write_without_read, &target)
|
||||
.unwrap_err()
|
||||
.0
|
||||
.contains("requires --permission read")
|
||||
);
|
||||
|
||||
let missing_workspace = TargetSelection {
|
||||
backend_url: Some("https://backend.example".to_string()),
|
||||
..TargetSelection::default()
|
||||
|
||||
+200
-74
@@ -14,9 +14,10 @@ use server_api::{
|
||||
use tokio_tungstenite::connect_async_with_config;
|
||||
use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig};
|
||||
use workdir::external::{
|
||||
ExternalProviderInstanceId, ExternalWorkdirGrantId, ExternalWorkdirOperationId,
|
||||
ExternalWorkdirOperationOutcome, ExternalWorkdirProviderFrame, ExternalWorkdirProviderMessage,
|
||||
ExternalWorkdirProviderRegistration, ExternalWorkdirServerFrame, ExternalWorkdirServerMessage,
|
||||
ExternalProviderInstanceId, ExternalWorkdirGrantId, ExternalWorkdirOperationError,
|
||||
ExternalWorkdirOperationId, ExternalWorkdirOperationOutcome, ExternalWorkdirProviderFrame,
|
||||
ExternalWorkdirProviderMessage, ExternalWorkdirProviderRegistration,
|
||||
ExternalWorkdirServerFrame, ExternalWorkdirServerMessage,
|
||||
};
|
||||
use workdir::http::{WorkdirTransportError, dispatch_workdir_session_operation};
|
||||
use workdir::{
|
||||
@@ -30,12 +31,23 @@ pub(crate) struct WorkdirShareOptions {
|
||||
pub backend_url: String,
|
||||
pub display_name: String,
|
||||
pub ttl: Option<Duration>,
|
||||
pub permission: server_api::ExternalWorkdirPermission,
|
||||
pub permissions: server_api::ExternalWorkdirPermissions,
|
||||
pub non_interactive: bool,
|
||||
}
|
||||
|
||||
const PROVIDER_OPERATION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation) -> Duration {
|
||||
if matches!(
|
||||
operation,
|
||||
workdir::http::WorkdirSessionOperation::CommandOutput(request) if request.wait
|
||||
) {
|
||||
Duration::from_secs(workdir::external::MAX_EXTERNAL_COMMAND_TIMEOUT_SECS + 10)
|
||||
} else {
|
||||
PROVIDER_OPERATION_TIMEOUT
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum ProviderEnd {
|
||||
Disconnected,
|
||||
@@ -128,13 +140,47 @@ fn authenticated_server_api_client(
|
||||
Ok(ServerApiClient::with_client(base_url, http_client))
|
||||
}
|
||||
|
||||
fn permission_label(permission: server_api::ExternalWorkdirPermission) -> &'static str {
|
||||
match permission {
|
||||
server_api::ExternalWorkdirPermission::ReadOnly => "read-only",
|
||||
server_api::ExternalWorkdirPermission::ReadWrite => {
|
||||
"read-write (files only; commands disabled)"
|
||||
fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> String {
|
||||
let mut enabled = Vec::new();
|
||||
if permissions.read {
|
||||
enabled.push("READ");
|
||||
}
|
||||
if permissions.write {
|
||||
enabled.push("WRITE");
|
||||
}
|
||||
if permissions.command {
|
||||
enabled.push("COMMAND");
|
||||
}
|
||||
enabled.join(", ")
|
||||
}
|
||||
|
||||
fn validate_permissions(permissions: server_api::ExternalWorkdirPermissions) -> Result<(), String> {
|
||||
if permissions.write && !permissions.read {
|
||||
return Err(
|
||||
"WRITE requires READ so existing files can satisfy read-before-write; select both categories"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if !permissions.read && !permissions.command {
|
||||
return Err("select at least one of READ or COMMAND".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn workdir_capabilities(
|
||||
permissions: server_api::ExternalWorkdirPermissions,
|
||||
) -> workdir::WorkdirSessionCapabilities {
|
||||
let mut capabilities = workdir::WorkdirSessionCapabilities::EMPTY;
|
||||
if permissions.read {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::READ_ONLY);
|
||||
}
|
||||
if permissions.write {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::WRITE_ONLY);
|
||||
}
|
||||
if permissions.command {
|
||||
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY);
|
||||
}
|
||||
capabilities
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -144,7 +190,7 @@ struct ResolvedWorkdirShareOptions {
|
||||
backend_url: String,
|
||||
display_name: String,
|
||||
ttl: Option<Duration>,
|
||||
permission: server_api::ExternalWorkdirPermission,
|
||||
permissions: server_api::ExternalWorkdirPermissions,
|
||||
}
|
||||
|
||||
pub(crate) fn parse_ttl(value: &str) -> Result<Duration, String> {
|
||||
@@ -254,30 +300,49 @@ fn configure_interactively(
|
||||
};
|
||||
options.workspace_id = Some(workspaces[selected_index].workspace_id.clone());
|
||||
|
||||
let current_permission = match options.permission {
|
||||
server_api::ExternalWorkdirPermission::ReadOnly => "r",
|
||||
server_api::ExternalWorkdirPermission::ReadWrite => "w",
|
||||
};
|
||||
writeln!(
|
||||
output,
|
||||
"Permission: read-only or read-write files. Read-write never enables Bash/command/exec."
|
||||
"Permissions are independent categories: READ (Read/Glob/Grep), WRITE (Write/Edit), and COMMAND (host command lifecycle). WRITE requires READ for read-before-write. COMMAND runs with this CLI user's host authority and is not sandboxed."
|
||||
)
|
||||
.map_err(|error| format!("failed to write prompt: {error}"))?;
|
||||
let Some(permission) = read_prompt_line(
|
||||
let Some(permission_input) = read_prompt_line(
|
||||
input,
|
||||
output,
|
||||
&format!("Permission [r/w; current {current_permission}; q to cancel]: "),
|
||||
&format!(
|
||||
"Permissions [comma-separated READ/WRITE/COMMAND; current {}; q to cancel]: ",
|
||||
permissions_label(options.permissions)
|
||||
),
|
||||
)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
options.permission = match permission.as_str() {
|
||||
"" => options.permission,
|
||||
"r" | "read-only" | "read_only" => server_api::ExternalWorkdirPermission::ReadOnly,
|
||||
"w" | "read-write" | "read_write" => server_api::ExternalWorkdirPermission::ReadWrite,
|
||||
"q" | "quit" | "cancel" => return Ok(None),
|
||||
_ => return Err("Permission must be read-only (`r`) or read-write (`w`)".to_string()),
|
||||
if matches!(permission_input.as_str(), "q" | "quit" | "cancel") {
|
||||
return Ok(None);
|
||||
}
|
||||
if !permission_input.is_empty() {
|
||||
let mut permissions = server_api::ExternalWorkdirPermissions {
|
||||
read: false,
|
||||
write: false,
|
||||
command: false,
|
||||
};
|
||||
for category in permission_input
|
||||
.split([',', ' '])
|
||||
.filter(|category| !category.is_empty())
|
||||
{
|
||||
match category.to_ascii_lowercase().as_str() {
|
||||
"r" | "read" | "read-only" | "read_only" => permissions.read = true,
|
||||
"w" | "write" | "read-write" | "read_write" => permissions.write = true,
|
||||
"c" | "command" => permissions.command = true,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"unknown permission category `{category}`; use READ, WRITE, or COMMAND"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
validate_permissions(permissions)?;
|
||||
options.permissions = permissions;
|
||||
}
|
||||
|
||||
writeln!(output, "TTL: {}", ttl_label(options.ttl))
|
||||
.map_err(|error| format!("failed to write prompt: {error}"))?;
|
||||
@@ -305,8 +370,8 @@ fn configure_interactively(
|
||||
.and_then(|()| {
|
||||
writeln!(
|
||||
output,
|
||||
" Permission: {}",
|
||||
permission_label(options.permission)
|
||||
" Permissions: {}",
|
||||
permissions_label(options.permissions)
|
||||
)
|
||||
})
|
||||
.and_then(|()| writeln!(output, " TTL: {}", ttl_label(options.ttl)))
|
||||
@@ -324,7 +389,7 @@ fn configure_interactively(
|
||||
backend_url: options.backend_url,
|
||||
display_name: options.display_name,
|
||||
ttl: options.ttl,
|
||||
permission: options.permission,
|
||||
permissions: options.permissions,
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -342,6 +407,7 @@ fn ensure_interactive_tty(stdin_is_tty: bool, stderr_is_tty: bool) -> Result<(),
|
||||
async fn resolve_share_options(
|
||||
options: WorkdirShareOptions,
|
||||
) -> Result<Option<ResolvedWorkdirShareOptions>, String> {
|
||||
validate_permissions(options.permissions)?;
|
||||
if options.non_interactive {
|
||||
let workspace_id = options.workspace_id.clone().ok_or_else(|| {
|
||||
"non-interactive sharing requires --workspace-id; no Workspace is inferred from cwd or path"
|
||||
@@ -353,7 +419,7 @@ async fn resolve_share_options(
|
||||
backend_url: options.backend_url,
|
||||
display_name: options.display_name,
|
||||
ttl: options.ttl,
|
||||
permission: options.permission,
|
||||
permissions: options.permissions,
|
||||
}));
|
||||
}
|
||||
ensure_interactive_tty(
|
||||
@@ -392,35 +458,29 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
|
||||
provider_instance_id: provider_instance_id.clone(),
|
||||
display_name: options.display_name.clone(),
|
||||
ttl_seconds: options.ttl.map(|ttl| ttl.as_secs()),
|
||||
permission: options.permission,
|
||||
permissions: options.permissions,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|error| format!("External Workdir grant request failed: {error}"))?;
|
||||
|
||||
if grant.permissions != options.permissions {
|
||||
return Err(
|
||||
"Backend returned permissions that differ from the requested grant".to_string(),
|
||||
);
|
||||
}
|
||||
let capabilities = workdir_capabilities(grant.permissions);
|
||||
let limits = BoundedReadLimits::EXTERNAL_DEFAULT;
|
||||
let session = match options.permission {
|
||||
server_api::ExternalWorkdirPermission::ReadOnly => {
|
||||
LocalWorkdirSession::external_read_only_pinned(
|
||||
Workdir::new(&grant.working_directory_id),
|
||||
pinned_root,
|
||||
limits,
|
||||
)
|
||||
}
|
||||
server_api::ExternalWorkdirPermission::ReadWrite => {
|
||||
LocalWorkdirSession::external_read_write_pinned(
|
||||
Workdir::new(&grant.working_directory_id),
|
||||
pinned_root,
|
||||
limits,
|
||||
)
|
||||
}
|
||||
}
|
||||
.map_err(|error| error.to_string())?;
|
||||
|
||||
println!("External Workdir grant: {}", grant.grant_id);
|
||||
println!("Logical Workdir: {}", grant.working_directory_id);
|
||||
println!("Display name: {}", grant.display_name);
|
||||
println!("Permission: {}", permission_label(grant.permission));
|
||||
println!("Permissions: {}", permissions_label(grant.permissions));
|
||||
if grant.permissions.command {
|
||||
println!(
|
||||
"COMMAND executes with this sharing CLI user's host authority; it is not sandboxed."
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"Expires at: {}",
|
||||
grant
|
||||
@@ -440,6 +500,16 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
|
||||
.transpose()?;
|
||||
let mut generation = grant.generation;
|
||||
loop {
|
||||
// Command handles and results are generation-owned. A reconnect always
|
||||
// gets a fresh LocalWorkdirSession, and closing the old one kills and
|
||||
// reaps every process before the next generation can register.
|
||||
let session = LocalWorkdirSession::external_with_capabilities_pinned(
|
||||
Workdir::new(&grant.working_directory_id),
|
||||
pinned_root.clone(),
|
||||
limits,
|
||||
capabilities,
|
||||
)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let outcome = serve_provider_connection(
|
||||
&backend_client,
|
||||
&server_api_client,
|
||||
@@ -450,6 +520,17 @@ pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> {
|
||||
generation,
|
||||
)
|
||||
.await;
|
||||
let close_result = session.close().await.map_err(|error| error.to_string());
|
||||
let outcome = match (outcome, close_result) {
|
||||
(Ok(outcome), Ok(())) => Ok(outcome),
|
||||
(Err(error), Ok(())) => Err(error),
|
||||
(Ok(_), Err(error)) => Err(format!(
|
||||
"External Workdir provider session cleanup failed: {error}"
|
||||
)),
|
||||
(Err(connection_error), Err(close_error)) => Err(format!(
|
||||
"{connection_error}; provider session cleanup failed: {close_error}"
|
||||
)),
|
||||
};
|
||||
let outcome = match outcome {
|
||||
Ok(outcome) => outcome,
|
||||
Err(connection_error) => {
|
||||
@@ -625,7 +706,7 @@ async fn serve_provider_connection(
|
||||
tokio::select! {
|
||||
signal = tokio::signal::ctrl_c() => {
|
||||
signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?;
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?;
|
||||
return Ok(ProviderEnd::Interrupted);
|
||||
}
|
||||
@@ -640,11 +721,12 @@ async fn serve_provider_connection(
|
||||
let cancelled = match operation.join() {
|
||||
Ok(cancelled) => cancelled,
|
||||
Err(error) => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let outcome = if cancelled {
|
||||
cancel_completed_command(session, &completion.outcome).await;
|
||||
ExternalWorkdirOperationOutcome::Cancelled
|
||||
} else {
|
||||
completion.outcome
|
||||
@@ -657,19 +739,19 @@ async fn serve_provider_connection(
|
||||
},
|
||||
);
|
||||
if send_provider_frame(&mut socket, &result).await.is_err() {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
}
|
||||
}
|
||||
incoming = socket.next() => {
|
||||
let Some(incoming) = incoming else {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
};
|
||||
let incoming = match incoming {
|
||||
Ok(incoming) => incoming,
|
||||
Err(_) => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
}
|
||||
};
|
||||
@@ -678,7 +760,7 @@ async fn serve_provider_connection(
|
||||
let frame = match serde_json::from_str::<ExternalWorkdirServerFrame>(&text) {
|
||||
Ok(frame) => frame,
|
||||
Err(error) => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Err(format!("Backend returned an invalid External Workdir frame: {error}"));
|
||||
}
|
||||
};
|
||||
@@ -689,34 +771,38 @@ async fn serve_provider_connection(
|
||||
operation,
|
||||
} if frame_generation == generation => {
|
||||
if operations.len() >= 16 || operations.contains_key(operation_id.as_str()) {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Err("Backend exceeded External Workdir operation bounds".to_string());
|
||||
}
|
||||
let key = operation_id.as_str().to_string();
|
||||
let completion_sender = completion_sender.clone();
|
||||
let task_operation_id = operation_id.clone();
|
||||
let cancelled = Arc::new(AtomicBool::new(false));
|
||||
let operation_timeout = provider_operation_timeout(operation.as_inner());
|
||||
let guarded_session = session.with_operation_guard(
|
||||
cancelled.clone(),
|
||||
Instant::now() + PROVIDER_OPERATION_TIMEOUT,
|
||||
Instant::now() + operation_timeout,
|
||||
);
|
||||
let operation = operation.into_inner();
|
||||
let runtime = tokio::runtime::Handle::current();
|
||||
let spawned = std::thread::Builder::new()
|
||||
.name("external-workdir-operation".to_string())
|
||||
.spawn(move || {
|
||||
let outcome = std::panic::catch_unwind(
|
||||
std::panic::AssertUnwindSafe(|| {
|
||||
futures::executor::block_on(execute_operation(
|
||||
runtime.block_on(execute_operation(
|
||||
&guarded_session,
|
||||
operation,
|
||||
))
|
||||
}),
|
||||
)
|
||||
.unwrap_or_else(|_| ExternalWorkdirOperationOutcome::Failed {
|
||||
error: WorkdirTransportError {
|
||||
error: ExternalWorkdirOperationError::from_transport_error(
|
||||
WorkdirTransportError {
|
||||
code: workdir::http::WorkdirTransportErrorCode::Internal,
|
||||
message: "External Workdir operation executor failed".to_string(),
|
||||
},
|
||||
),
|
||||
});
|
||||
let _ = completion_sender.blocking_send(OperationCompletion {
|
||||
operation_id: task_operation_id,
|
||||
@@ -726,7 +812,7 @@ async fn serve_provider_connection(
|
||||
let thread = match spawned {
|
||||
Ok(thread) => thread,
|
||||
Err(error) => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Err(format!("failed to start External Workdir operation: {error}"));
|
||||
}
|
||||
};
|
||||
@@ -746,7 +832,7 @@ async fn serve_provider_connection(
|
||||
ExternalWorkdirProviderMessage::Heartbeat { generation, sequence },
|
||||
);
|
||||
if send_provider_frame(&mut socket, &heartbeat).await.is_err() {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
}
|
||||
}
|
||||
@@ -764,7 +850,7 @@ async fn serve_provider_connection(
|
||||
generation: frame_generation,
|
||||
..
|
||||
} if frame_generation == generation => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
let acknowledged = ExternalWorkdirProviderFrame::current(
|
||||
ExternalWorkdirProviderMessage::RevokeAcknowledged { generation },
|
||||
);
|
||||
@@ -772,7 +858,7 @@ async fn serve_provider_connection(
|
||||
return Ok(ProviderEnd::Revoked);
|
||||
}
|
||||
_ => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Err("stale or unexpected External Workdir provider frame".to_string());
|
||||
}
|
||||
}
|
||||
@@ -784,12 +870,12 @@ async fn serve_provider_connection(
|
||||
)
|
||||
.await;
|
||||
if !matches!(pong, Ok(Ok(()))) {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
}
|
||||
}
|
||||
Message::Close(_) => {
|
||||
stop_operations(&mut operations, &mut completions).await?;
|
||||
stop_provider_operations(session, &mut operations, &mut completions).await?;
|
||||
return Ok(ProviderEnd::Disconnected);
|
||||
}
|
||||
_ => {}
|
||||
@@ -799,6 +885,28 @@ async fn serve_provider_connection(
|
||||
}
|
||||
}
|
||||
|
||||
async fn cancel_completed_command(
|
||||
session: &LocalWorkdirSession,
|
||||
outcome: &ExternalWorkdirOperationOutcome,
|
||||
) {
|
||||
let ExternalWorkdirOperationOutcome::Completed { result } = outcome else {
|
||||
return;
|
||||
};
|
||||
let workdir::http::WorkdirSessionOperationResult::CommandStart(handle) = result.as_inner()
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let _ = session.cancel_command(handle.clone()).await;
|
||||
let _ = session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: handle.clone(),
|
||||
cursor: 0,
|
||||
limit: workdir::external::MAX_EXTERNAL_COMMAND_OUTPUT_BYTES,
|
||||
wait: true,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn execute_operation(
|
||||
session: &LocalWorkdirSession,
|
||||
operation: workdir::http::WorkdirSessionOperation,
|
||||
@@ -807,14 +915,16 @@ async fn execute_operation(
|
||||
Ok(result) => match result.try_into() {
|
||||
Ok(result) => ExternalWorkdirOperationOutcome::Completed { result },
|
||||
Err(message) => ExternalWorkdirOperationOutcome::Failed {
|
||||
error: WorkdirTransportError {
|
||||
error: ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
|
||||
code: workdir::http::WorkdirTransportErrorCode::Unsupported,
|
||||
message,
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
Err(error) => ExternalWorkdirOperationOutcome::Failed {
|
||||
error: WorkdirTransportError::from_workdir_error(&error),
|
||||
error: ExternalWorkdirOperationError::from_transport_error(
|
||||
WorkdirTransportError::from_workdir_error(&error),
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -825,6 +935,15 @@ fn cancel_operations(operations: &HashMap<String, OperationTask>) {
|
||||
}
|
||||
}
|
||||
|
||||
async fn stop_provider_operations(
|
||||
session: &LocalWorkdirSession,
|
||||
operations: &mut HashMap<String, OperationTask>,
|
||||
completions: &mut tokio::sync::mpsc::Receiver<OperationCompletion>,
|
||||
) -> Result<(), String> {
|
||||
session.close().await.map_err(|error| error.to_string())?;
|
||||
stop_operations(operations, completions).await
|
||||
}
|
||||
|
||||
async fn stop_operations(
|
||||
operations: &mut HashMap<String, OperationTask>,
|
||||
completions: &mut tokio::sync::mpsc::Receiver<OperationCompletion>,
|
||||
@@ -925,7 +1044,7 @@ mod tests {
|
||||
backend_url: "https://backend.example".to_string(),
|
||||
display_name: "External Workdir".to_string(),
|
||||
ttl: None,
|
||||
permission: server_api::ExternalWorkdirPermission::ReadOnly,
|
||||
permissions: server_api::ExternalWorkdirPermissions::default(),
|
||||
non_interactive: false,
|
||||
}
|
||||
}
|
||||
@@ -954,8 +1073,8 @@ mod tests {
|
||||
|
||||
assert_eq!(selected.workspace_id, "workspace-a");
|
||||
assert_eq!(
|
||||
selected.permission,
|
||||
server_api::ExternalWorkdirPermission::ReadOnly
|
||||
selected.permissions,
|
||||
server_api::ExternalWorkdirPermissions::default()
|
||||
);
|
||||
assert_eq!(selected.ttl, None);
|
||||
assert!(
|
||||
@@ -966,12 +1085,12 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interactive_share_requires_explicit_read_write_selection_and_confirmation() {
|
||||
fn interactive_share_requires_explicit_write_and_command_selection_and_confirmation() {
|
||||
let workspaces = [
|
||||
workspace("workspace-a", "Alpha"),
|
||||
workspace("workspace-b", "Beta"),
|
||||
];
|
||||
let mut input = std::io::Cursor::new(b"2\nw\n15m\ny\n".to_vec());
|
||||
let mut input = std::io::Cursor::new(b"2\nREAD,WRITE,COMMAND\n15m\ny\n".to_vec());
|
||||
let mut output = Vec::new();
|
||||
|
||||
let selected =
|
||||
@@ -981,10 +1100,17 @@ mod tests {
|
||||
|
||||
assert_eq!(selected.workspace_id, "workspace-b");
|
||||
assert_eq!(
|
||||
selected.permission,
|
||||
server_api::ExternalWorkdirPermission::ReadWrite
|
||||
selected.permissions,
|
||||
server_api::ExternalWorkdirPermissions {
|
||||
read: true,
|
||||
write: true,
|
||||
command: true,
|
||||
}
|
||||
);
|
||||
assert_eq!(selected.ttl, Some(Duration::from_secs(900)));
|
||||
let output = String::from_utf8(output).unwrap();
|
||||
assert!(output.contains("not sandboxed"));
|
||||
assert!(output.contains("Permissions: READ, WRITE, COMMAND"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1101,7 +1227,7 @@ mod tests {
|
||||
"working_directory_id": "workdir-a",
|
||||
"provider_instance_id": "provider-a",
|
||||
"display_name": "Shared files",
|
||||
"permission": "read_only",
|
||||
"permissions": {"read": true, "write": false, "command": false},
|
||||
"expires_at": "2026-09-23T03:00:00Z",
|
||||
"generation": 7,
|
||||
"status": "pending"
|
||||
@@ -1121,7 +1247,7 @@ mod tests {
|
||||
provider_instance_id: "provider-a".to_string(),
|
||||
display_name: "Shared files".to_string(),
|
||||
ttl_seconds: Some(600),
|
||||
permission: server_api::ExternalWorkdirPermission::ReadOnly,
|
||||
permissions: server_api::ExternalWorkdirPermissions::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
# External Workdir sharing permissions
|
||||
|
||||
`yoi workdir share` exposes a client-hosted directory through an authenticated,
|
||||
outbound Backend connection. The operator grants three independent categories:
|
||||
|
||||
- **READ**: file reads and read-only discovery operations such as Stat, List,
|
||||
ViewImage, Glob, and Grep.
|
||||
- **WRITE**: Write and Edit. WRITE requires READ because updating an existing
|
||||
file uses the read-before-write content-hash contract. Selecting WRITE never
|
||||
enables COMMAND.
|
||||
- **COMMAND**: command start, status, bounded output, and cancellation. COMMAND
|
||||
does not enable READ or WRITE. It executes on the sharing host with the OS
|
||||
authority of the user running the CLI; External Workdir command execution is
|
||||
**not a sandbox** and does not confine the process to the shared directory.
|
||||
|
||||
READ is enabled by default and COMMAND is disabled by default. Interactive mode
|
||||
asks for a comma-separated category list and shows the exact grant before it is
|
||||
created. For automation, repeat `--permission`:
|
||||
|
||||
```sh
|
||||
yoi --backend https://example.invalid workdir share ./project \
|
||||
--workspace-id workspace-a \
|
||||
--permission read \
|
||||
--permission write \
|
||||
--permission command \
|
||||
--non-interactive
|
||||
```
|
||||
|
||||
`--read-only` remains an alias for READ. `--read-write` remains an alias for
|
||||
READ+WRITE and never enables COMMAND. These legacy flags conflict with explicit
|
||||
`--permission` arguments so ambiguous automation fails closed.
|
||||
|
||||
Omitting `--ttl` creates no automatic expiry. An explicit TTL must be between
|
||||
60 seconds and 24 hours. Ctrl-C, revoke, expiry, provider disconnect, attachment
|
||||
detach, and provider-generation replacement terminate and reap active commands.
|
||||
A reconnect creates a fresh command namespace; old handles and results are not
|
||||
accepted by the new generation.
|
||||
|
||||
Command output uses the existing bounded inline contract. Worker-local spill
|
||||
paths are not sent to the sharing provider, and provider-local paths are never
|
||||
returned to the Backend or model. Truncated External command output is therefore
|
||||
reported without a full-output host path.
|
||||
@@ -121,6 +121,15 @@ ref = "anthropic/claude-sonnet-4-6"
|
||||
# reasoning = "medium"
|
||||
# reasoning = -1
|
||||
|
||||
# 任意。デフォルト: "after_response"。
|
||||
# "on_tool_call_complete" は provider が個別 ToolCall の完成を保証する場合だけ、
|
||||
# そのブロック完成後に実行開始を前倒しする。安全な完成境界がない provider、または
|
||||
# 応答全体を見て実行前 Pause を判断する turn-end hook がある場合は、応答完了まで
|
||||
# 警告付きで明示的に遅延する。これは直列実行モードではなく、先行 call の完了を
|
||||
# 待たず後続 call も開始するため、完了順・DB 反映順・採番順は保証しない。
|
||||
# 早期開始後に stream が切断された場合は自動 continuation せず、安全に terminalize して停止する。
|
||||
# tool_call_dispatch = "on_tool_call_complete"
|
||||
|
||||
# 任意。tool 実行 content の byte 長キャップ。
|
||||
# セクション省略時は default_max_bytes = 64 * 1024、per_tool 空。
|
||||
# [worker.tool_output]
|
||||
|
||||
+39
-13
@@ -2015,8 +2015,8 @@
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"permission": {
|
||||
"$ref": "#/components/schemas/ExternalWorkdirPermission"
|
||||
"permissions": {
|
||||
"$ref": "#/components/schemas/ExternalWorkdirPermissions"
|
||||
},
|
||||
"provider_instance_id": {
|
||||
"type": "string"
|
||||
@@ -2034,7 +2034,7 @@
|
||||
"required": [
|
||||
"provider_instance_id",
|
||||
"display_name",
|
||||
"permission"
|
||||
"permissions"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -2060,8 +2060,8 @@
|
||||
"grant_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"permission": {
|
||||
"$ref": "#/components/schemas/ExternalWorkdirPermission"
|
||||
"permissions": {
|
||||
"$ref": "#/components/schemas/ExternalWorkdirPermissions"
|
||||
},
|
||||
"provider_instance_id": {
|
||||
"type": "string"
|
||||
@@ -2082,18 +2082,34 @@
|
||||
"working_directory_id",
|
||||
"provider_instance_id",
|
||||
"display_name",
|
||||
"permission",
|
||||
"permissions",
|
||||
"generation",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ExternalWorkdirPermission": {
|
||||
"enum": [
|
||||
"read_only",
|
||||
"read_write"
|
||||
"ExternalWorkdirPermissions": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"command": {
|
||||
"description": "Execute commands as the sharing CLI user on the provider host. This is\nexplicit authority and does not imply either filesystem category.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"read": {
|
||||
"description": "Read files and use read-only discovery operations such as Glob and Grep.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"write": {
|
||||
"description": "Create or update files with Write and Edit. Existing-file mutation also\nrequires `read` so callers can satisfy the read-before-write contract.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"read",
|
||||
"write",
|
||||
"command"
|
||||
],
|
||||
"type": "string"
|
||||
"type": "object"
|
||||
},
|
||||
"ExtractAudit": {
|
||||
"properties": {
|
||||
@@ -11924,12 +11940,17 @@
|
||||
"alias": {
|
||||
"type": "string"
|
||||
},
|
||||
"effective_permissions": {
|
||||
"$ref": "#/components/schemas/ExternalWorkdirPermissions",
|
||||
"description": "Current effective category authority after intersecting the durable link,\nsource grant, and Ticket target ceilings. This is independent from\nprovider liveness and may be narrower than the grant ceiling."
|
||||
},
|
||||
"working_directory": {
|
||||
"$ref": "#/components/schemas/WorkingDirectorySummary"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"alias",
|
||||
"effective_permissions",
|
||||
"working_directory"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -12219,6 +12240,10 @@
|
||||
"grant_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"grant_permissions": {
|
||||
"$ref": "#/components/schemas/ExternalWorkdirPermissions",
|
||||
"description": "Operator-approved ceiling for this External Workdir grant. Worker\nattachment authority is projected separately after attenuation."
|
||||
},
|
||||
"kind": {
|
||||
"const": "external_grant",
|
||||
"type": "string"
|
||||
@@ -12226,7 +12251,8 @@
|
||||
},
|
||||
"required": [
|
||||
"kind",
|
||||
"grant_id"
|
||||
"grant_id",
|
||||
"grant_permissions"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
@@ -13994,7 +14020,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a"
|
||||
"x-yoi-source-digest": "sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
@@ -15,6 +15,8 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
|
||||
|
||||
export type GitCommitSummary = { author_date: string; author_email: string; author_name: string; hash: string; parents: Array<string>; refs: Array<string>; short_hash: string; summary: string; };
|
||||
|
||||
export type GitRemoteSummary = { fetch_url: string; name: string; };
|
||||
@@ -73,7 +75,7 @@ export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState;
|
||||
|
||||
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; capabilities: WorkerCapabilitySummary; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
||||
|
||||
export type WorkerWorkdirAttachmentSummary = { alias: string; working_directory: WorkingDirectorySummary; };
|
||||
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
|
||||
|
||||
export type WorkerWorkspaceSummary = { identity: string; visibility: string; workspace_id?: string | null; };
|
||||
|
||||
@@ -83,7 +85,7 @@ export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hos
|
||||
|
||||
export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; };
|
||||
|
||||
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; };
|
||||
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
|
||||
|
||||
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
@@ -27,6 +27,8 @@ export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type ExecuteRuntimeCleanupRequest = { confirm_dirty_discard_target_ids?: Array<string>; expected_plan_digest: string; expected_plan_revision: string; workdir_target_ids?: Array<string>; worker_target_ids?: Array<string>; };
|
||||
|
||||
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
|
||||
|
||||
export type RemoveRuntimeRequest = { expected_binding_revision: number; operation_id: string; };
|
||||
|
||||
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
|
||||
@@ -163,7 +165,7 @@ export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState;
|
||||
|
||||
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; capabilities: WorkerCapabilitySummary; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
||||
|
||||
export type WorkerWorkdirAttachmentSummary = { alias: string; working_directory: WorkingDirectorySummary; };
|
||||
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
|
||||
|
||||
export type WorkerWorkspaceSummary = { identity: string; visibility: string; workspace_id?: string | null; };
|
||||
|
||||
@@ -173,7 +175,7 @@ export type WorkingDirectoryMaterializerKind = "runtime_git_clone" | "client_hos
|
||||
|
||||
export type WorkingDirectoryOccupancy = { display_name: string; linked_at: string; runtime_id: string; worker_id: string; };
|
||||
|
||||
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; };
|
||||
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
|
||||
|
||||
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
@@ -9,11 +9,11 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type ExternalWorkdirGrantCreateRequest = { display_name: string; permission: ExternalWorkdirPermission; provider_instance_id: string; ttl_seconds?: number | null; };
|
||||
export type ExternalWorkdirGrantCreateRequest = { display_name: string; permissions: ExternalWorkdirPermissions; provider_instance_id: string; ttl_seconds?: number | null; };
|
||||
|
||||
export type ExternalWorkdirGrantResponse = { display_name: string; expires_at?: string | null; generation: number; grant_id: string; permission: ExternalWorkdirPermission; provider_instance_id: string; status: string; working_directory_id: string; workspace_id: string; };
|
||||
export type ExternalWorkdirGrantResponse = { display_name: string; expires_at?: string | null; generation: number; grant_id: string; permissions: ExternalWorkdirPermissions; provider_instance_id: string; status: string; working_directory_id: string; workspace_id: string; };
|
||||
|
||||
export type ExternalWorkdirPermission = "read_only" | "read_write";
|
||||
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
|
||||
|
||||
export type RepositoryApiError = { diagnostics?: Array<Diagnostic>; error: string; message: string; };
|
||||
|
||||
@@ -37,7 +37,7 @@ export type WorkingDirectoryRemovalRequest = { reason: string; };
|
||||
|
||||
export type WorkingDirectoryRemovalResponse = { disposition: WorkingDirectoryRemovalDisposition; failure_category?: string | null; retryable: boolean; working_directory_id: string; };
|
||||
|
||||
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; };
|
||||
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
|
||||
|
||||
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:845d8859bab4de95f0539969ae8c84bbf3fb4a0548638e4278d6c90f9bb6cb3a)
|
||||
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
@@ -19,6 +19,8 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type ExternalWorkdirPermissions = { command: boolean; read: boolean; write: boolean; };
|
||||
|
||||
export type PasteArtifactAvailability = "available" | "unavailable" | "integrity_failed";
|
||||
|
||||
export type PasteArtifactMediaType = "text_plain_utf8";
|
||||
@@ -67,7 +69,7 @@ export type WorkerStateSnapshot = { last_command_id: number; state: WorkerState;
|
||||
|
||||
export type WorkerSummary = { availability: SubscriptionWorkerAvailability; capabilities: WorkerCapabilitySummary; diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; resource_key: string; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<WorkerWorkdirAttachmentSummary>; worker_id: string; worker_state?: WorkerStateSnapshot | null; workspace: WorkerWorkspaceSummary; };
|
||||
|
||||
export type WorkerWorkdirAttachmentSummary = { alias: string; working_directory: WorkingDirectorySummary; };
|
||||
export type WorkerWorkdirAttachmentSummary = { alias: string; effective_permissions: ExternalWorkdirPermissions; working_directory: WorkingDirectorySummary; };
|
||||
|
||||
export type WorkerWorkspaceSummary = { identity: string; visibility: string; workspace_id?: string | null; };
|
||||
|
||||
@@ -79,7 +81,7 @@ export type WorkingDirectoryOccupancy = { display_name: string; linked_at: strin
|
||||
|
||||
export type WorkingDirectoryRepositoryOption = { default_selector?: string | null; repository_key: string; };
|
||||
|
||||
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; kind: "external_grant"; };
|
||||
export type WorkingDirectorySource = { kind: "repository"; repository_key: string; } | { grant_id: string; grant_permissions: ExternalWorkdirPermissions; kind: "external_grant"; };
|
||||
|
||||
export type WorkingDirectoryStatusKind = "active" | "cleanup_pending" | "corrupted" | "not_found" | "unknown";
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
Diagnostic,
|
||||
ExternalWorkdirPermissions,
|
||||
WorkingDirectoryCleanupTarget,
|
||||
WorkingDirectoryCreateRequest,
|
||||
WorkingDirectoryCreateResponse,
|
||||
@@ -176,13 +177,15 @@ export function parseWorkingDirectorySummary(
|
||||
function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
|
||||
const source = exactRecord(
|
||||
value,
|
||||
new Set(["kind", "repository_key", "grant_id"]),
|
||||
new Set(["kind", "repository_key", "grant_id", "grant_permissions"]),
|
||||
"Workdir source",
|
||||
);
|
||||
const kind = stringField(source, "kind");
|
||||
if (kind === "repository") {
|
||||
if (source.grant_id !== undefined) {
|
||||
throw new Error("Repository Workdir source must not contain grant_id");
|
||||
if (source.grant_id !== undefined || source.grant_permissions !== undefined) {
|
||||
throw new Error(
|
||||
"Repository Workdir source must not contain External grant authority",
|
||||
);
|
||||
}
|
||||
return {
|
||||
kind,
|
||||
@@ -198,11 +201,31 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
|
||||
return {
|
||||
kind,
|
||||
grant_id: stringField(source, "grant_id"),
|
||||
grant_permissions: parseExternalWorkdirPermissions(
|
||||
source.grant_permissions,
|
||||
"External Workdir grant permissions",
|
||||
),
|
||||
};
|
||||
}
|
||||
throw new Error("Workdir source.kind has an unsupported value");
|
||||
}
|
||||
|
||||
export function parseExternalWorkdirPermissions(
|
||||
value: unknown,
|
||||
label = "External Workdir permissions",
|
||||
): ExternalWorkdirPermissions {
|
||||
const permissions = exactRecord(
|
||||
value,
|
||||
new Set(["read", "write", "command"]),
|
||||
label,
|
||||
);
|
||||
return {
|
||||
read: booleanField(permissions, "read", label),
|
||||
write: booleanField(permissions, "write", label),
|
||||
command: booleanField(permissions, "command", label),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
|
||||
const record = exactRecord(
|
||||
value,
|
||||
@@ -260,6 +283,18 @@ function stringField(record: Record<string, unknown>, key: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
function booleanField(
|
||||
record: Record<string, unknown>,
|
||||
key: string,
|
||||
label: string,
|
||||
): boolean {
|
||||
const value = record[key];
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`${label}.${key} must be a boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function arrayField(record: Record<string, unknown>, key: string): unknown[] {
|
||||
const value = record[key];
|
||||
if (!Array.isArray(value)) throw new Error(`${key} must be an array`);
|
||||
|
||||
@@ -96,6 +96,48 @@ Deno.test("Worker summary parser enforces observation freshness", () => {
|
||||
assertEquals(observedStopped.state, "stopped");
|
||||
});
|
||||
|
||||
Deno.test("Worker summary parser preserves attachment-effective External permissions", () => {
|
||||
const attachment = {
|
||||
alias: "sessions",
|
||||
effective_permissions: { read: false, write: false, command: true },
|
||||
working_directory: {
|
||||
working_directory_id: "external-1",
|
||||
display_name: "Sessions",
|
||||
source: {
|
||||
kind: "external_grant",
|
||||
grant_id: "grant-1",
|
||||
grant_permissions: { read: true, write: false, command: true },
|
||||
},
|
||||
materializer_kind: "client_hosted_external",
|
||||
status: "active",
|
||||
},
|
||||
};
|
||||
const parsed = parseWorkerSummary({
|
||||
...worker,
|
||||
resource_key: "W-1",
|
||||
availability: "observed",
|
||||
workdir_attachments: [attachment],
|
||||
});
|
||||
assertEquals(
|
||||
parsed.workdir_attachments?.[0]?.effective_permissions,
|
||||
attachment.effective_permissions,
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWorkerSummary({
|
||||
...worker,
|
||||
resource_key: "W-1",
|
||||
availability: "observed",
|
||||
workdir_attachments: [{
|
||||
alias: attachment.alias,
|
||||
working_directory: attachment.working_directory,
|
||||
}],
|
||||
}),
|
||||
Error,
|
||||
"effective_permissions",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Worker launch options parser accepts the generated wire shape", () => {
|
||||
const parsed = parseWorkerLaunchOptionsResponse({
|
||||
workspace_id: "workspace-a",
|
||||
|
||||
@@ -24,7 +24,10 @@ import type {
|
||||
WorkingDirectoryRepositoryOption,
|
||||
} from "$lib/generated/worker-launch-api";
|
||||
import type { Segment } from "$lib/generated/protocol";
|
||||
import { parseWorkingDirectorySummary } from "$lib/workspace/api/workdirs";
|
||||
import {
|
||||
parseExternalWorkdirPermissions,
|
||||
parseWorkingDirectorySummary,
|
||||
} from "$lib/workspace/api/workdirs";
|
||||
|
||||
const DIAGNOSTIC_SEVERITIES = new Set<DiagnosticSeverity>([
|
||||
"info",
|
||||
@@ -316,9 +319,13 @@ function workerWorkdirAttachment(
|
||||
label: string,
|
||||
): WorkerWorkdirAttachmentSummary {
|
||||
const item = record(value, label);
|
||||
exact(item, ["alias", "working_directory"], label);
|
||||
exact(item, ["alias", "effective_permissions", "working_directory"], label);
|
||||
return {
|
||||
alias: string(item.alias, `${label}.alias`),
|
||||
effective_permissions: parseExternalWorkdirPermissions(
|
||||
item.effective_permissions,
|
||||
`${label}.effective_permissions`,
|
||||
),
|
||||
working_directory: parseWorkingDirectorySummary(item.working_directory),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { ExternalWorkdirPermissions } from "$lib/generated/workdir-api";
|
||||
|
||||
/** Render independent Workdir authority categories in a stable, non-conflating order. */
|
||||
export function formatWorkdirPermissions(
|
||||
permissions: ExternalWorkdirPermissions,
|
||||
): string {
|
||||
const categories: string[] = [];
|
||||
if (permissions.read) categories.push("READ");
|
||||
if (permissions.write) categories.push("WRITE");
|
||||
if (permissions.command) categories.push("COMMAND");
|
||||
return categories.length > 0 ? categories.join(" · ") : "NONE";
|
||||
}
|
||||
@@ -32,7 +32,11 @@ Deno.test("defaultWorkerLaunchForm preserves an available read-only External Wor
|
||||
const external = {
|
||||
working_directory_id: "external-1",
|
||||
display_name: "Session analysis",
|
||||
source: { kind: "external_grant" as const, grant_id: "grant-1" },
|
||||
source: {
|
||||
kind: "external_grant" as const,
|
||||
grant_id: "grant-1",
|
||||
grant_permissions: { read: true, write: false, command: false },
|
||||
},
|
||||
materializer_kind: "client_hosted_external" as const,
|
||||
status: "active" as const,
|
||||
cleanliness: "unknown",
|
||||
|
||||
@@ -111,7 +111,11 @@ Deno.test("Worker sidebar Workdir label uses an External Workdir display name wi
|
||||
const meta = sidebarWorkdirMeta([
|
||||
attachment("sessions", {
|
||||
display_name: "Session analysis",
|
||||
source: { kind: "external_grant", grant_id: "grant-1" },
|
||||
source: {
|
||||
kind: "external_grant",
|
||||
grant_id: "grant-1",
|
||||
grant_permissions: { read: true, write: false, command: false },
|
||||
},
|
||||
materializer_kind: "client_hosted_external",
|
||||
cleanliness: "unknown",
|
||||
}),
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
parseWorkerRetentionResponse,
|
||||
} from '$lib/workspace/api/runtime-workers';
|
||||
import { workerHref } from '$lib/workspace/resource-links';
|
||||
import { formatWorkdirPermissions } from '$lib/workspace/settings/workdir-permissions';
|
||||
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
|
||||
import { canOpenWorkerConsole } from '$lib/workspace/sidebar/workers';
|
||||
import { liveWorkerState } from '$lib/workspace/sidebar/worker-state';
|
||||
@@ -149,7 +150,7 @@
|
||||
function workerDirectory(worker: Worker): string {
|
||||
const attachments = worker.workdir_attachments ?? [];
|
||||
if (attachments.length === 0) return '—';
|
||||
return attachments.map(({ alias, working_directory: directory }) => {
|
||||
return attachments.map(({ alias, effective_permissions, working_directory: directory }) => {
|
||||
const repositoryKey = directory.source.kind === 'repository'
|
||||
? directory.source.repository_key
|
||||
: null;
|
||||
@@ -157,6 +158,9 @@
|
||||
? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider
|
||||
: null;
|
||||
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
|
||||
if (directory.source.kind === 'external_grant') {
|
||||
return `${alias}: ${label} · ${formatWorkdirPermissions(effective_permissions)}`;
|
||||
}
|
||||
return `${alias}: ${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
|
||||
}).join(', ');
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
parseWorkerLaunchOptionsResponse,
|
||||
} from '$lib/workspace/api/workers';
|
||||
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
|
||||
import { formatWorkdirPermissions } from '$lib/workspace/settings/workdir-permissions';
|
||||
import {
|
||||
buildCreateWorkspaceWorkerRequest,
|
||||
defaultWorkerLaunchForm,
|
||||
@@ -38,6 +39,9 @@
|
||||
? data.repositories?.items.find((repository) => repository.repository_key === repositoryKey)?.provider
|
||||
: null;
|
||||
const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
|
||||
if (directory.source.kind === 'external_grant') {
|
||||
return `${label} · External · ${formatWorkdirPermissions(directory.source.grant_permissions)}`;
|
||||
}
|
||||
return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -53,7 +53,11 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
|
||||
item: {
|
||||
working_directory_id: "external-1",
|
||||
display_name: "Session analysis",
|
||||
source: { kind: "external_grant", grant_id: "grant-1" },
|
||||
source: {
|
||||
kind: "external_grant",
|
||||
grant_id: "grant-1",
|
||||
grant_permissions: { read: true, write: false, command: true },
|
||||
},
|
||||
materializer_kind: "client_hosted_external",
|
||||
status: "active",
|
||||
cleanliness: "unknown",
|
||||
@@ -66,6 +70,52 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
|
||||
if (detail.item.source.kind !== "external_grant") {
|
||||
throw new Error("External Workdir source was not preserved");
|
||||
}
|
||||
if (
|
||||
!detail.item.source.grant_permissions.read ||
|
||||
detail.item.source.grant_permissions.write ||
|
||||
!detail.item.source.grant_permissions.command
|
||||
) {
|
||||
throw new Error("External Workdir grant permissions were not preserved");
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Workdir REST validation rejects missing or misplaced External grant permissions", () => {
|
||||
for (
|
||||
const source of [
|
||||
{ kind: "external_grant", grant_id: "grant-1" },
|
||||
{
|
||||
kind: "external_grant",
|
||||
grant_id: "grant-1",
|
||||
grant_permissions: { read: true, write: false, command: "yes" },
|
||||
},
|
||||
{
|
||||
kind: "repository",
|
||||
repository_key: "main",
|
||||
grant_permissions: { read: true, write: false, command: false },
|
||||
},
|
||||
]
|
||||
) {
|
||||
let rejected = false;
|
||||
try {
|
||||
parseWorkingDirectoryDetailResponse({
|
||||
workspace_id: "workspace-a",
|
||||
item: {
|
||||
working_directory_id: "invalid-1",
|
||||
source,
|
||||
materializer_kind: source.kind === "repository"
|
||||
? "runtime_git_clone"
|
||||
: "client_hosted_external",
|
||||
status: "active",
|
||||
},
|
||||
diagnostics: [],
|
||||
});
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
if (!rejected) {
|
||||
throw new Error(`invalid External permission projection was accepted: ${JSON.stringify(source)}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Workdir REST validation rejects stale response JSON", () => {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import { formatWorkdirPermissions } from "../src/lib/workspace/settings/workdir-permissions.ts";
|
||||
|
||||
Deno.test("Workdir permissions render independent categories in stable order", () => {
|
||||
const cases = [
|
||||
[{ read: true, write: false, command: false }, "READ"],
|
||||
[{ read: true, write: true, command: false }, "READ · WRITE"],
|
||||
[{ read: false, write: false, command: true }, "COMMAND"],
|
||||
[{ read: true, write: false, command: true }, "READ · COMMAND"],
|
||||
[{ read: true, write: true, command: true }, "READ · WRITE · COMMAND"],
|
||||
] as const;
|
||||
|
||||
for (const [permissions, expected] of cases) {
|
||||
const actual = formatWorkdirPermissions(permissions);
|
||||
if (actual !== expected) {
|
||||
throw new Error(`expected ${expected}, got ${actual}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user