chore: merge develop into worker state snapshot
This commit is contained in:
@@ -12,8 +12,10 @@ use workspace_api::{
|
|||||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||||
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest,
|
||||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse,
|
RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse,
|
||||||
|
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||||
|
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||||
@@ -241,6 +243,53 @@ impl BackendWorkspaceProductClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_runtimes(
|
||||||
|
&self,
|
||||||
|
) -> Result<ListResponse<WorkspaceRuntimeResource>, BackendWorkspaceClientError> {
|
||||||
|
self.get_json("/runtimes")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn runtime_detail(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||||
|
self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reveal_runtime_trust_key(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
) -> Result<RuntimeTrustKeyRevealResponse, BackendWorkspaceClientError> {
|
||||||
|
self.get_json(&format!(
|
||||||
|
"/runtimes/{}/trust-key",
|
||||||
|
encode_path_segment(runtime_id)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put_runtime_trust_key(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
request: &PutRuntimeTrustKeyRequest,
|
||||||
|
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||||
|
self.send_json(
|
||||||
|
Method::PUT,
|
||||||
|
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
|
||||||
|
Some(request),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn revoke_runtime_trust_key(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
request: &RevokeRuntimeTrustKeyRequest,
|
||||||
|
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||||
|
self.send_json(
|
||||||
|
Method::DELETE,
|
||||||
|
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
|
||||||
|
Some(request),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
|
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
|
||||||
self.get_json("/memory")
|
self.get_json("/memory")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ impl Tool for BashTool {
|
|||||||
command: params.command,
|
command: params.command,
|
||||||
timeout_secs,
|
timeout_secs,
|
||||||
output_limit: INLINE_BYTE_BUDGET,
|
output_limit: INLINE_BYTE_BUDGET,
|
||||||
|
cwd: None,
|
||||||
spill_dir: Some(self.output_dir.clone()),
|
spill_dir: Some(self.output_dir.clone()),
|
||||||
tool_call_id: Some(call_id.clone()),
|
tool_call_id: Some(call_id.clone()),
|
||||||
})
|
})
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -68,12 +68,10 @@ pub enum WorkdirSessionOperation {
|
|||||||
CommandCancel(CommandHandle),
|
CommandCancel(CommandHandle),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire envelope for an operation and its optional provider-enforced child scope.
|
/// Wire envelope for one provider operation.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkdirSessionOperationRequest {
|
pub struct WorkdirSessionOperationRequest {
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub delegations: Vec<crate::WorkdirDelegationRequest>,
|
|
||||||
pub operation: WorkdirSessionOperation,
|
pub operation: WorkdirSessionOperation,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,7 +287,7 @@ mod client {
|
|||||||
use reqwest::{Client, StatusCode, Url};
|
use reqwest::{Client, StatusCode, Url};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::{Workdir, WorkdirSession, WorkdirSessionHandle};
|
use crate::{Workdir, WorkdirSession};
|
||||||
|
|
||||||
/// Provides a fresh bearer token for each Runtime request. Backend
|
/// Provides a fresh bearer token for each Runtime request. Backend
|
||||||
/// implementations can mint short-lived capability tokens without making a
|
/// implementations can mint short-lived capability tokens without making a
|
||||||
@@ -324,7 +322,6 @@ mod client {
|
|||||||
workdir: Workdir,
|
workdir: Workdir,
|
||||||
session_id: WorkdirSessionId,
|
session_id: WorkdirSessionId,
|
||||||
capabilities: WorkdirSessionCapabilities,
|
capabilities: WorkdirSessionCapabilities,
|
||||||
delegations: Vec<crate::WorkdirDelegationRequest>,
|
|
||||||
closed: AtomicBool,
|
closed: AtomicBool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,7 +374,6 @@ mod client {
|
|||||||
workdir: Workdir::new(opened.workdir_id.as_str()),
|
workdir: Workdir::new(opened.workdir_id.as_str()),
|
||||||
session_id: opened.session_id,
|
session_id: opened.session_id,
|
||||||
capabilities: opened.capabilities,
|
capabilities: opened.capabilities,
|
||||||
delegations: Vec::new(),
|
|
||||||
closed: AtomicBool::new(false),
|
closed: AtomicBool::new(false),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -404,10 +400,7 @@ mod client {
|
|||||||
"operations",
|
"operations",
|
||||||
],
|
],
|
||||||
)?;
|
)?;
|
||||||
let operation = WorkdirSessionOperationRequest {
|
let operation = WorkdirSessionOperationRequest { operation };
|
||||||
delegations: self.delegations.clone(),
|
|
||||||
operation,
|
|
||||||
};
|
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
.post(url)
|
.post(url)
|
||||||
@@ -436,37 +429,6 @@ mod client {
|
|||||||
self.capabilities
|
self.capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transports_delegation_context(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn capture_delegation_source(
|
|
||||||
&self,
|
|
||||||
request: &crate::WorkdirDelegationRequest,
|
|
||||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
|
||||||
if self.closed.load(Ordering::Acquire) {
|
|
||||||
return Err(WorkdirError::SessionClosed);
|
|
||||||
}
|
|
||||||
let mut delegations = self.delegations.clone();
|
|
||||||
delegations.push(request.clone());
|
|
||||||
let candidate = Arc::new(Self {
|
|
||||||
client: self.client.clone(),
|
|
||||||
base_url: self.base_url.clone(),
|
|
||||||
authorization: self.authorization.clone(),
|
|
||||||
workdir: self.workdir.clone(),
|
|
||||||
session_id: self.session_id.clone(),
|
|
||||||
capabilities: self.capabilities,
|
|
||||||
delegations,
|
|
||||||
closed: AtomicBool::new(false),
|
|
||||||
});
|
|
||||||
candidate
|
|
||||||
.stat(StatRequest {
|
|
||||||
path: fs_operation::FsPath::new("").expect("empty Workdir path is valid"),
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
Ok(candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
match self.operate(WorkdirSessionOperation::Stat(request)).await? {
|
||||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||||
|
|||||||
@@ -5,10 +5,10 @@
|
|||||||
//! bound to one Worker. Tools consume sessions; they do not own Workdir
|
//! bound to one Worker. Tools consume sessions; they do not own Workdir
|
||||||
//! materialization or cleanup.
|
//! materialization or cleanup.
|
||||||
|
|
||||||
mod delegation;
|
|
||||||
pub mod http;
|
pub mod http;
|
||||||
mod local;
|
mod local;
|
||||||
mod operation;
|
mod operation;
|
||||||
|
mod scope;
|
||||||
pub mod workspace;
|
pub mod workspace;
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -18,11 +18,6 @@ use async_trait::async_trait;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::broadcast;
|
||||||
|
|
||||||
pub use delegation::{
|
|
||||||
AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation,
|
|
||||||
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule,
|
|
||||||
apply_delegation_chain, delegation_capable_session,
|
|
||||||
};
|
|
||||||
pub use fs_operation::{
|
pub use fs_operation::{
|
||||||
ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest,
|
ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest,
|
||||||
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
|
GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, ListResult,
|
||||||
@@ -32,6 +27,10 @@ pub use local::{
|
|||||||
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
|
LocalWorkdirSession, SymlinkInfo, WorkdirSessionResource, direct_symlink, first_symlink,
|
||||||
};
|
};
|
||||||
pub use operation::*;
|
pub use operation::*;
|
||||||
|
pub use scope::{
|
||||||
|
ReadOnlyWorkdirSession, WorkdirScopeLease, WorkdirToolBroker, WorkdirToolScope,
|
||||||
|
WorkdirToolScopePermission, WorkdirToolScopeRule,
|
||||||
|
};
|
||||||
|
|
||||||
/// Persistent, opaque identity of one materialized Workdir.
|
/// Persistent, opaque identity of one materialized Workdir.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
@@ -148,39 +147,6 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
|
|||||||
fn workdir(&self) -> &Workdir;
|
fn workdir(&self) -> &Workdir;
|
||||||
fn capabilities(&self) -> WorkdirSessionCapabilities;
|
fn capabilities(&self) -> WorkdirSessionCapabilities;
|
||||||
|
|
||||||
fn is_delegation_capable(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this session transports the delegation chain to another
|
|
||||||
/// provider boundary that will apply logical cwd/path resolution there.
|
|
||||||
fn transports_delegation_context(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Capture a provider-specific source for a delegated child session.
|
|
||||||
/// Remote providers use this boundary to pin attachment identity without
|
|
||||||
/// exposing transport handles or host paths.
|
|
||||||
async fn capture_delegation_source(
|
|
||||||
&self,
|
|
||||||
_request: &WorkdirDelegationRequest,
|
|
||||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
|
||||||
Err(WorkdirError::Denied(
|
|
||||||
"workdir provider does not support delegated sessions".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Attenuate this session into a revocable child lease. Only sessions
|
|
||||||
/// created with [`delegation_capable_session`] implement this operation.
|
|
||||||
async fn delegate(
|
|
||||||
&self,
|
|
||||||
_request: WorkdirDelegationRequest,
|
|
||||||
) -> Result<WorkdirDelegation, WorkdirError> {
|
|
||||||
Err(WorkdirError::Denied(
|
|
||||||
"workdir session is not delegation-capable".into(),
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError>;
|
||||||
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
async fn read(&self, request: ReadRequest) -> Result<ReadResult, WorkdirError>;
|
||||||
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
|
async fn write(&self, request: WriteRequest) -> Result<WriteResult, WorkdirError>;
|
||||||
|
|||||||
+23
-69
@@ -18,7 +18,7 @@ use std::sync::{Arc, Mutex as StdMutex};
|
|||||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope};
|
use manifest::{Scope, SharedScope};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
use tokio::sync::{Mutex, broadcast, watch};
|
use tokio::sync::{Mutex, broadcast, watch};
|
||||||
@@ -28,10 +28,8 @@ use crate::{
|
|||||||
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest,
|
||||||
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
|
CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult,
|
||||||
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
|
GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest,
|
||||||
ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission,
|
ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, WorkdirSession,
|
||||||
WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession,
|
WorkdirSessionCapabilities, WorkdirSessionCapability, WriteRequest, WriteResult,
|
||||||
WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest,
|
|
||||||
WriteResult,
|
|
||||||
};
|
};
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::{EntryKind, WriteOutcome};
|
use crate::{EntryKind, WriteOutcome};
|
||||||
@@ -558,69 +556,6 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
self.inner.capabilities
|
self.inner.capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn capture_delegation_source(
|
|
||||||
&self,
|
|
||||||
request: &WorkdirDelegationRequest,
|
|
||||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
|
||||||
let host_rules = request
|
|
||||||
.rules
|
|
||||||
.iter()
|
|
||||||
.map(|rule| ScopeRule {
|
|
||||||
target: self.inner.root.join(rule.target.as_str()),
|
|
||||||
permission: match rule.permission {
|
|
||||||
WorkdirDelegationPermission::Read => Permission::Read,
|
|
||||||
WorkdirDelegationPermission::Write => Permission::Write,
|
|
||||||
},
|
|
||||||
recursive: rule.recursive,
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
for (logical, host) in request.rules.iter().zip(&host_rules) {
|
|
||||||
if logical.permission == WorkdirDelegationPermission::Write {
|
|
||||||
let resolved = Scope::resolved_target(host)
|
|
||||||
.map_err(|error| WorkdirError::Denied(error.to_string()))?;
|
|
||||||
if resolved != host.target {
|
|
||||||
return Err(WorkdirError::Denied(format!(
|
|
||||||
"write delegation target `{}` traverses a symlink",
|
|
||||||
logical.target
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let parent_scope = self.inner.scope.snapshot();
|
|
||||||
for rule in &host_rules {
|
|
||||||
if !parent_scope
|
|
||||||
.allows_rule(rule)
|
|
||||||
.map_err(|error| WorkdirError::Denied(error.to_string()))?
|
|
||||||
{
|
|
||||||
return Err(WorkdirError::Denied(format!(
|
|
||||||
"delegated provider scope `{}` exceeds the parent session",
|
|
||||||
rule.target.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let child_scope = Scope::from_config(&ScopeConfig {
|
|
||||||
allow: host_rules,
|
|
||||||
deny: Vec::new(),
|
|
||||||
})
|
|
||||||
.map_err(|error| WorkdirError::Denied(error.to_string()))?;
|
|
||||||
let child_cwd = self.inner.root.join(request.cwd.as_str());
|
|
||||||
if !child_scope.is_readable(&child_cwd)
|
|
||||||
|| !std::fs::metadata(&child_cwd).is_ok_and(|metadata| metadata.is_dir())
|
|
||||||
{
|
|
||||||
return Err(WorkdirError::Denied(format!(
|
|
||||||
"delegated cwd `{}` is not a readable Workdir directory",
|
|
||||||
request.cwd
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
Ok(Arc::new(LocalWorkdirSession::materialized_bound(
|
|
||||||
self.inner.workdir.clone(),
|
|
||||||
self.inner.root.clone(),
|
|
||||||
self.inner.root.clone(),
|
|
||||||
SharedScope::new(child_scope),
|
|
||||||
self.inner.capabilities,
|
|
||||||
)))
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
self.ensure_capability(WorkdirSessionCapability::Read)?;
|
||||||
let logical = request.path.clone();
|
let logical = request.path.clone();
|
||||||
@@ -694,9 +629,20 @@ impl WorkdirSession for LocalWorkdirSession {
|
|||||||
{
|
{
|
||||||
return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf()));
|
return Err(WorkdirError::OutOfScope(spill_dir.to_path_buf()));
|
||||||
}
|
}
|
||||||
|
let cwd = if let Some(logical_cwd) = request.cwd.as_ref() {
|
||||||
|
let cwd = self.resolve(logical_cwd);
|
||||||
|
let scope = self.inner.scope.snapshot();
|
||||||
|
if !scope.is_readable(&cwd)
|
||||||
|
|| !std::fs::metadata(&cwd).is_ok_and(|metadata| metadata.is_dir())
|
||||||
|
{
|
||||||
|
return Err(WorkdirError::OutOfScope(cwd));
|
||||||
|
}
|
||||||
|
cwd
|
||||||
|
} else {
|
||||||
|
self.inner.cwd.clone()
|
||||||
|
};
|
||||||
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
|
let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed);
|
||||||
let handle = CommandHandle(format!("command-{id}"));
|
let handle = CommandHandle(format!("command-{id}"));
|
||||||
let cwd = self.inner.cwd.clone();
|
|
||||||
let (completion_tx, completion) = watch::channel(false);
|
let (completion_tx, completion) = watch::channel(false);
|
||||||
let command_id = handle.0.clone();
|
let command_id = handle.0.clone();
|
||||||
let telemetry = self.inner.command_telemetry.clone();
|
let telemetry = self.inner.command_telemetry.clone();
|
||||||
@@ -1516,6 +1462,7 @@ mod tests {
|
|||||||
command: "sleep 30".to_owned(),
|
command: "sleep 30".to_owned(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
spill_dir: None,
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
@@ -2043,6 +1990,7 @@ mod tests {
|
|||||||
command: "pwd && printf provider-command".into(),
|
command: "pwd && printf provider-command".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 4096,
|
output_limit: 4096,
|
||||||
|
cwd: None,
|
||||||
spill_dir: None,
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
@@ -2141,6 +2089,7 @@ mod tests {
|
|||||||
command: "printf hidden".into(),
|
command: "printf hidden".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1,
|
output_limit: 1,
|
||||||
|
cwd: None,
|
||||||
spill_dir: Some(spill.path().to_path_buf()),
|
spill_dir: Some(spill.path().to_path_buf()),
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
@@ -2178,6 +2127,7 @@ mod tests {
|
|||||||
command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(),
|
command: "i=0; while [ $i -lt 200 ]; do printf 'line-%03d\\n' \"$i\"; i=$((i+1)); done; printf 'FINAL-NEEDLE\\n'".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 64,
|
output_limit: 64,
|
||||||
|
cwd: None,
|
||||||
spill_dir: Some(spill.path().to_path_buf()),
|
spill_dir: Some(spill.path().to_path_buf()),
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
@@ -2224,6 +2174,7 @@ mod tests {
|
|||||||
command: "printf 'aéz'".into(),
|
command: "printf 'aéz'".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
spill_dir: None,
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
@@ -2449,6 +2400,7 @@ mod tests {
|
|||||||
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
|
command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
spill_dir: None,
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-7".into()),
|
tool_call_id: Some("tool-7".into()),
|
||||||
},
|
},
|
||||||
@@ -2553,6 +2505,7 @@ mod tests {
|
|||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
timeout_secs: 1,
|
timeout_secs: 1,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
spill_dir: None,
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
@@ -2623,6 +2576,7 @@ mod tests {
|
|||||||
command: "sleep 30".into(),
|
command: "sleep 30".into(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
spill_dir: None,
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ pub struct CommandRequest {
|
|||||||
pub command: String,
|
pub command: String,
|
||||||
pub timeout_secs: u64,
|
pub timeout_secs: u64,
|
||||||
pub output_limit: usize,
|
pub output_limit: usize,
|
||||||
|
/// Workdir-relative command directory. Providers validate it against the
|
||||||
|
/// active session before process start.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cwd: Option<fs_operation::FsPath>,
|
||||||
/// Provider-local directory where complete output is retained when the
|
/// Provider-local directory where complete output is retained when the
|
||||||
/// inline result exceeds `output_limit`.
|
/// inline result exceeds `output_limit`.
|
||||||
pub spill_dir: Option<PathBuf>,
|
pub spill_dir: Option<PathBuf>,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -104,15 +104,5 @@ mod tests {
|
|||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceWorkdirSessionOperationRequest {
|
pub struct WorkspaceWorkdirSessionOperationRequest {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub expected_session_fence: Option<String>,
|
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
||||||
pub delegations: Vec<crate::WorkdirDelegationRequest>,
|
|
||||||
pub operation: crate::http::WorkdirSessionOperation,
|
pub operation: crate::http::WorkdirSessionOperation,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
||||||
#[serde(deny_unknown_fields)]
|
|
||||||
pub struct WorkspaceWorkdirSessionFence {
|
|
||||||
pub value: String,
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -774,8 +774,7 @@ async fn run_workdir_session_operation(
|
|||||||
.ok_or_else(RuntimeHttpWorkdirError::not_found)?;
|
.ok_or_else(RuntimeHttpWorkdirError::not_found)?;
|
||||||
record.session.clone()
|
record.session.clone()
|
||||||
};
|
};
|
||||||
let applied = workdir::apply_delegation_chain(source, request.delegations).await?;
|
let session = source.as_ref();
|
||||||
let session = applied.scoped_session.as_ref();
|
|
||||||
let operation = request.operation;
|
let operation = request.operation;
|
||||||
|
|
||||||
let result = match operation {
|
let result = match operation {
|
||||||
@@ -2215,8 +2214,8 @@ mod tests {
|
|||||||
use manifest::{Scope, SharedScope};
|
use manifest::{Scope, SharedScope};
|
||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use workdir::{
|
use workdir::{
|
||||||
GrepOutputMode, GrepRequest, LocalWorkdirSession, ReadRequest, StatRequest, Workdir,
|
GrepOutputMode, GrepRequest, LocalWorkdirSession, StatRequest, Workdir, WorkdirPath,
|
||||||
WorkdirPath, WorkdirSessionCapabilities,
|
WorkdirSessionCapabilities,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -2770,16 +2769,6 @@ mod tests {
|
|||||||
async fn workdir_session_operations_enforce_owner_and_close_terminally() {
|
async fn workdir_session_operations_enforce_owner_and_close_terminally() {
|
||||||
let temp = tempfile::tempdir().expect("tempdir");
|
let temp = tempfile::tempdir().expect("tempdir");
|
||||||
std::fs::write(temp.path().join("hello.txt"), "hello").expect("write fixture");
|
std::fs::write(temp.path().join("hello.txt"), "hello").expect("write fixture");
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
use std::os::unix::fs::symlink;
|
|
||||||
std::fs::create_dir(temp.path().join("granted")).expect("granted directory");
|
|
||||||
std::fs::write(temp.path().join("granted/visible"), "visible")
|
|
||||||
.expect("visible fixture");
|
|
||||||
std::fs::create_dir(temp.path().join("secret")).expect("secret directory");
|
|
||||||
std::fs::write(temp.path().join("secret/key"), "hidden").expect("secret fixture");
|
|
||||||
symlink("../secret/key", temp.path().join("granted/link")).expect("symlink fixture");
|
|
||||||
}
|
|
||||||
let scope = SharedScope::new(Scope::writable(temp.path()).expect("scope"));
|
let scope = SharedScope::new(Scope::writable(temp.path()).expect("scope"));
|
||||||
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
|
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
|
||||||
Workdir::new("wd-1"),
|
Workdir::new("wd-1"),
|
||||||
@@ -2813,7 +2802,6 @@ mod tests {
|
|||||||
expires_at: u64::MAX,
|
expires_at: u64::MAX,
|
||||||
};
|
};
|
||||||
let operation = WorkdirSessionOperationRequest {
|
let operation = WorkdirSessionOperationRequest {
|
||||||
delegations: Vec::new(),
|
|
||||||
operation: WorkdirSessionOperation::Stat(StatRequest {
|
operation: WorkdirSessionOperation::Stat(StatRequest {
|
||||||
path: WorkdirPath::new("hello.txt").expect("logical path"),
|
path: WorkdirPath::new("hello.txt").expect("logical path"),
|
||||||
}),
|
}),
|
||||||
@@ -2830,7 +2818,6 @@ mod tests {
|
|||||||
assert!(matches!(result, WorkdirSessionOperationResult::Stat(_)));
|
assert!(matches!(result, WorkdirSessionOperationResult::Stat(_)));
|
||||||
|
|
||||||
let grep = WorkdirSessionOperationRequest {
|
let grep = WorkdirSessionOperationRequest {
|
||||||
delegations: Vec::new(),
|
|
||||||
operation: WorkdirSessionOperation::Grep(GrepRequest {
|
operation: WorkdirSessionOperation::Grep(GrepRequest {
|
||||||
pattern: "hello".into(),
|
pattern: "hello".into(),
|
||||||
path: WorkdirPath::new("hello.txt").unwrap(),
|
path: WorkdirPath::new("hello.txt").unwrap(),
|
||||||
@@ -2853,78 +2840,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("grep direct file through provider operation");
|
.expect("grep direct file through provider operation");
|
||||||
match result {
|
assert!(matches!(result, WorkdirSessionOperationResult::Grep(_)));
|
||||||
WorkdirSessionOperationResult::Grep(result) => {
|
|
||||||
assert_eq!(result.match_count, 1);
|
|
||||||
assert_eq!(result.matched_files, 1);
|
|
||||||
assert!(result.output.starts_with("hello.txt\n"));
|
|
||||||
assert!(result.output.contains("> 1 │ hello"));
|
|
||||||
}
|
|
||||||
other => panic!("unexpected workdir grep result: {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(unix)]
|
|
||||||
{
|
|
||||||
let delegated_visible = WorkdirSessionOperationRequest {
|
|
||||||
delegations: vec![workdir::WorkdirDelegationRequest {
|
|
||||||
rules: vec![workdir::WorkdirDelegationRule {
|
|
||||||
target: WorkdirPath::new("granted").unwrap(),
|
|
||||||
permission: workdir::WorkdirDelegationPermission::Read,
|
|
||||||
recursive: true,
|
|
||||||
}],
|
|
||||||
cwd: WorkdirPath::new("granted").unwrap(),
|
|
||||||
}],
|
|
||||||
operation: WorkdirSessionOperation::Read(ReadRequest {
|
|
||||||
path: WorkdirPath::new("visible").unwrap(),
|
|
||||||
offset: 0,
|
|
||||||
limit: 20,
|
|
||||||
max_bytes: 1024,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
let visible = run_workdir_session_operation(
|
|
||||||
State(state.clone()),
|
|
||||||
Path("session-1".to_string()),
|
|
||||||
Some(Extension(auth.clone())),
|
|
||||||
Ok(Json(delegated_visible)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect("non-root delegated cwd should resolve once")
|
|
||||||
.0;
|
|
||||||
assert!(matches!(
|
|
||||||
visible,
|
|
||||||
WorkdirSessionOperationResult::Read(result) if result.bytes == b"visible"
|
|
||||||
));
|
|
||||||
|
|
||||||
let delegated_read = WorkdirSessionOperationRequest {
|
|
||||||
delegations: vec![workdir::WorkdirDelegationRequest {
|
|
||||||
rules: vec![workdir::WorkdirDelegationRule {
|
|
||||||
target: WorkdirPath::new("granted").unwrap(),
|
|
||||||
permission: workdir::WorkdirDelegationPermission::Read,
|
|
||||||
recursive: true,
|
|
||||||
}],
|
|
||||||
cwd: WorkdirPath::new("granted").unwrap(),
|
|
||||||
}],
|
|
||||||
operation: WorkdirSessionOperation::Read(ReadRequest {
|
|
||||||
path: WorkdirPath::new("link").unwrap(),
|
|
||||||
offset: 0,
|
|
||||||
limit: 20,
|
|
||||||
max_bytes: 1024,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
let error = run_workdir_session_operation(
|
|
||||||
State(state.clone()),
|
|
||||||
Path("session-1".to_string()),
|
|
||||||
Some(Extension(auth.clone())),
|
|
||||||
Ok(Json(delegated_read)),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.expect_err("provider must reject delegated symlink escape");
|
|
||||||
assert_ne!(error.status, StatusCode::OK);
|
|
||||||
assert_eq!(
|
|
||||||
std::fs::read_to_string(temp.path().join("secret/key")).unwrap(),
|
|
||||||
"hidden"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let wrong_owner = RuntimeAuthContext {
|
let wrong_owner = RuntimeAuthContext {
|
||||||
workspace_id: "workspace-b".to_string(),
|
workspace_id: "workspace-b".to_string(),
|
||||||
|
|||||||
@@ -705,6 +705,7 @@ impl WorkerController {
|
|||||||
runtime_base.to_path_buf(),
|
runtime_base.to_path_buf(),
|
||||||
spawned_registry.clone(),
|
spawned_registry.clone(),
|
||||||
Some(method_tx.downgrade()),
|
Some(method_tx.downgrade()),
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
if let Some(session) = fs_for_view.as_ref() {
|
if let Some(session) = fs_for_view.as_ref() {
|
||||||
@@ -1116,6 +1117,7 @@ pub(crate) async fn register_worker_tools<C, St>(
|
|||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
spawned_registry: Arc<SpawnedWorkerRegistry>,
|
||||||
parent_method_tx: Option<mpsc::WeakSender<Method>>,
|
parent_method_tx: Option<mpsc::WeakSender<Method>>,
|
||||||
|
inherited_workdir_tool_broker: Option<workdir::WorkdirToolBroker>,
|
||||||
) -> std::io::Result<Option<workdir::WorkdirSessionHandle>>
|
) -> std::io::Result<Option<workdir::WorkdirSessionHandle>>
|
||||||
where
|
where
|
||||||
C: LlmClient + Clone + 'static,
|
C: LlmClient + Clone + 'static,
|
||||||
@@ -1124,21 +1126,26 @@ where
|
|||||||
// Worker-immutable snapshots taken before the mutable worker borrow
|
// Worker-immutable snapshots taken before the mutable worker borrow
|
||||||
// below so the worker borrow doesn't conflict with reads on `worker`.
|
// below so the worker borrow doesn't conflict with reads on `worker`.
|
||||||
let feature_config = worker.manifest().feature.clone();
|
let feature_config = worker.manifest().feature.clone();
|
||||||
|
let mut workdir_tool_broker = inherited_workdir_tool_broker;
|
||||||
if feature_config.manage_workdir.enabled && worker.workdir_session().is_none() {
|
if feature_config.manage_workdir.enabled && worker.workdir_session().is_none() {
|
||||||
let workspace_client = worker.workspace_client_handle();
|
let workspace_client = worker.workspace_client_handle();
|
||||||
worker.bind_workdir_session(Some(workdir::delegation_capable_session(
|
let broker = workdir::WorkdirToolBroker::new(
|
||||||
crate::feature::builtin::manage_workdir::WorkspaceAttachedWorkdirSession::handle(
|
crate::feature::builtin::manage_workdir::WorkspaceAttachedWorkdirSession::handle(
|
||||||
workspace_client,
|
workspace_client,
|
||||||
),
|
),
|
||||||
)));
|
);
|
||||||
}
|
worker.bind_workdir_session(Some(broker.tool_session()));
|
||||||
if feature_config.sub_worker.enabled
|
workdir_tool_broker = Some(broker);
|
||||||
|
} else if workdir_tool_broker.is_none()
|
||||||
&& let Some(existing) = worker.workdir_session().cloned()
|
&& let Some(existing) = worker.workdir_session().cloned()
|
||||||
&& !existing.is_delegation_capable()
|
|
||||||
{
|
{
|
||||||
worker.bind_workdir_session(Some(workdir::delegation_capable_session(existing)));
|
let broker = workdir::WorkdirToolBroker::new(existing);
|
||||||
|
worker.bind_workdir_session(Some(broker.tool_session()));
|
||||||
|
workdir_tool_broker = Some(broker);
|
||||||
}
|
}
|
||||||
let worker_workdir = worker.workdir_session().cloned();
|
let worker_workdir = workdir_tool_broker
|
||||||
|
.as_ref()
|
||||||
|
.map(workdir::WorkdirToolBroker::tool_session);
|
||||||
let local_filesystem = worker.local_working_directory().cloned();
|
let local_filesystem = worker.local_working_directory().cloned();
|
||||||
let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone());
|
let local_workspace_root = local_filesystem.as_ref().map(|local| local.root.clone());
|
||||||
let task_feature = worker.task_feature();
|
let task_feature = worker.task_feature();
|
||||||
@@ -1305,8 +1312,17 @@ where
|
|||||||
"manage Workdir tools require Backend Workspace API authority",
|
"manage Workdir tools require Backend Workspace API authority",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let shutdown_registry = spawned_registry.clone();
|
||||||
|
let reopen_registry = spawned_registry.clone();
|
||||||
feature_registry.add_module(
|
feature_registry.add_module(
|
||||||
crate::feature::builtin::manage_workdir::manage_workdir_feature(workspace_client),
|
crate::feature::builtin::manage_workdir::ManageWorkdirFeature::with_child_lifecycle(
|
||||||
|
workspace_client,
|
||||||
|
Arc::new(move || {
|
||||||
|
let child_registry = shutdown_registry.clone();
|
||||||
|
Box::pin(async move { child_registry.shutdown_internal().await })
|
||||||
|
}),
|
||||||
|
Arc::new(move || reopen_registry.reopen_internal()),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if feature_config.workspace_worker_discovery.enabled {
|
if feature_config.workspace_worker_discovery.enabled {
|
||||||
@@ -1368,7 +1384,6 @@ where
|
|||||||
}
|
}
|
||||||
|
|
||||||
let host_worker_observation_provider = worker.worker_observation_provider();
|
let host_worker_observation_provider = worker.worker_observation_provider();
|
||||||
let source_workdir_session = worker.workdir_session().cloned();
|
|
||||||
{
|
{
|
||||||
let workspace_client = worker.workspace_client_handle();
|
let workspace_client = worker.workspace_client_handle();
|
||||||
let engine = worker.engine_mut();
|
let engine = worker.engine_mut();
|
||||||
@@ -1410,7 +1425,7 @@ where
|
|||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
bash_output_dir.clone(),
|
bash_output_dir.clone(),
|
||||||
spawner_workspace_root,
|
spawner_workspace_root,
|
||||||
source_workdir_session,
|
workdir_tool_broker,
|
||||||
spawned_registry.clone(),
|
spawned_registry.clone(),
|
||||||
spawner_manifest,
|
spawner_manifest,
|
||||||
prompts,
|
prompts,
|
||||||
@@ -2292,7 +2307,16 @@ async fn controller_loop<C, St>(
|
|||||||
// Memory/Workdir teardown so they cannot observe a partially closed Worker.
|
// Memory/Workdir teardown so they cannot observe a partially closed Worker.
|
||||||
worker.stop_feature_runtime("controller shutdown").await;
|
worker.stop_feature_runtime("controller shutdown").await;
|
||||||
|
|
||||||
if let Some(session) = worker.workdir_session()
|
let child_cleanup_succeeded = match spawned_registry.shutdown_internal().await {
|
||||||
|
Ok(()) => true,
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "Internal SubWorker cleanup failed before Workdir shutdown");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if child_cleanup_succeeded
|
||||||
|
&& let Some(session) = worker.workdir_session()
|
||||||
&& let Err(error) = session.close().await
|
&& let Err(error) = session.close().await
|
||||||
{
|
{
|
||||||
tracing::warn!(%error, "Workdir session close failed");
|
tracing::warn!(%error, "Workdir session close failed");
|
||||||
@@ -3702,4 +3726,21 @@ mod tests {
|
|||||||
.is_ok()
|
.is_ok()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn controller_shutdown_orders_child_cleanup_before_workdir_close() {
|
||||||
|
let source = include_str!("controller.rs");
|
||||||
|
let shutdown_start = source
|
||||||
|
.rfind("worker.stop_feature_runtime(\"controller shutdown\")")
|
||||||
|
.expect("controller shutdown block");
|
||||||
|
let shutdown = &source[shutdown_start..];
|
||||||
|
let children = shutdown
|
||||||
|
.find("spawned_registry.shutdown_internal().await")
|
||||||
|
.expect("Internal SubWorker cleanup");
|
||||||
|
let workdir = shutdown
|
||||||
|
.find("session.close().await")
|
||||||
|
.expect("parent Workdir close");
|
||||||
|
assert!(children < workdir);
|
||||||
|
assert!(shutdown.contains("if child_cleanup_succeeded"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
//! endpoints, credentials, materializer handles, and operation sessions stay
|
//! endpoints, credentials, materializer handles, and operation sessions stay
|
||||||
//! behind [`WorkspaceClient`].
|
//! behind [`WorkspaceClient`].
|
||||||
|
|
||||||
|
use std::future::Future;
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
@@ -12,7 +14,7 @@ use async_trait::async_trait;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
|
use workdir::http::{WorkdirSessionOperation, WorkdirSessionOperationResult};
|
||||||
use workdir::workspace::{WorkspaceWorkdirSessionFence, WorkspaceWorkdirSessionOperationRequest};
|
use workdir::workspace::WorkspaceWorkdirSessionOperationRequest;
|
||||||
use workdir::{
|
use workdir::{
|
||||||
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest,
|
||||||
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult,
|
||||||
@@ -52,16 +54,48 @@ const LIST_DESCRIPTION: &str = "List persistent Workdirs in the current Workspac
|
|||||||
const CREATE_DESCRIPTION: &str = "Materialize a persistent Workdir on a selected Runtime from a Workspace repository and optional selector. This does not change this Worker's attachment; use WorkdirAttach explicitly after creation.";
|
const CREATE_DESCRIPTION: &str = "Materialize a persistent Workdir on a selected Runtime from a Workspace repository and optional selector. This does not change this Worker's attachment; use WorkdirAttach explicitly after creation.";
|
||||||
const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. The Backend enforces one active Workdir per Worker and one active Worker per Workdir, then opens an ephemeral operation session.";
|
const ATTACH_DESCRIPTION: &str = "Attach this Worker to one existing Workdir. The Backend enforces one active Workdir per Worker and one active Worker per Workdir, then opens an ephemeral operation session.";
|
||||||
const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed.";
|
const DETACH_DESCRIPTION: &str = "Detach this Worker from its active Workdir and release Workdir occupancy. Any ephemeral operation session is closed.";
|
||||||
|
pub(crate) type BeforeWorkdirRelease =
|
||||||
|
Arc<dyn Fn() -> Pin<Box<dyn Future<Output = std::io::Result<()>> + Send>> + Send + Sync>;
|
||||||
|
pub(crate) type AfterWorkdirAttach = Arc<dyn Fn() + Send + Sync>;
|
||||||
|
|
||||||
const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by id through durable Backend Workspace authority. The input includes only the Workdir id and a bounded reason. The result reports removed, retained, or attention_required without exposing operation-table or provider internals.";
|
const DELETE_DESCRIPTION: &str = "Request removal of one persistent Workdir by id through durable Backend Workspace authority. The input includes only the Workdir id and a bounded reason. The result reports removed, retained, or attention_required without exposing operation-table or provider internals.";
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone)]
|
||||||
pub struct ManageWorkdirFeature {
|
pub struct ManageWorkdirFeature {
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
before_workdir_release: Option<BeforeWorkdirRelease>,
|
||||||
|
after_workdir_attach: Option<AfterWorkdirAttach>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for ManageWorkdirFeature {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("ManageWorkdirFeature")
|
||||||
|
.field("client_kind", &self.client.kind())
|
||||||
|
.field("release_guard", &self.before_workdir_release.is_some())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ManageWorkdirFeature {
|
impl ManageWorkdirFeature {
|
||||||
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
pub fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||||
Self { client }
|
Self {
|
||||||
|
client,
|
||||||
|
before_workdir_release: None,
|
||||||
|
after_workdir_attach: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn with_child_lifecycle(
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
before_workdir_release: BeforeWorkdirRelease,
|
||||||
|
after_workdir_attach: AfterWorkdirAttach,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
client,
|
||||||
|
before_workdir_release: Some(before_workdir_release),
|
||||||
|
after_workdir_attach: Some(after_workdir_attach),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +115,10 @@ impl FeatureModule for ManageWorkdirFeature {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone());
|
let backend = WorkspaceHttpWorkdirBackend::new(self.client.clone()).with_child_lifecycle(
|
||||||
|
self.before_workdir_release.clone(),
|
||||||
|
self.after_workdir_attach.clone(),
|
||||||
|
);
|
||||||
for (name, definition) in [
|
for (name, definition) in [
|
||||||
(
|
(
|
||||||
LIST_TOOL,
|
LIST_TOOL,
|
||||||
@@ -142,9 +179,21 @@ impl FeatureModule for ManageWorkdirFeature {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone)]
|
||||||
struct WorkspaceHttpWorkdirBackend {
|
struct WorkspaceHttpWorkdirBackend {
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
before_workdir_release: Option<BeforeWorkdirRelease>,
|
||||||
|
after_workdir_attach: Option<AfterWorkdirAttach>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Debug for WorkspaceHttpWorkdirBackend {
|
||||||
|
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
formatter
|
||||||
|
.debug_struct("WorkspaceHttpWorkdirBackend")
|
||||||
|
.field("client_kind", &self.client.kind())
|
||||||
|
.field("release_guard", &self.before_workdir_release.is_some())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Worker-local Workdir handle whose operation authority remains in the Workspace Backend.
|
/// Worker-local Workdir handle whose operation authority remains in the Workspace Backend.
|
||||||
@@ -156,8 +205,6 @@ struct WorkspaceHttpWorkdirBackend {
|
|||||||
pub struct WorkspaceAttachedWorkdirSession {
|
pub struct WorkspaceAttachedWorkdirSession {
|
||||||
client: Arc<dyn WorkspaceClient>,
|
client: Arc<dyn WorkspaceClient>,
|
||||||
workdir: Workdir,
|
workdir: Workdir,
|
||||||
expected_session_fence: Option<String>,
|
|
||||||
delegations: Vec<workdir::WorkdirDelegationRequest>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkspaceAttachedWorkdirSession {
|
impl WorkspaceAttachedWorkdirSession {
|
||||||
@@ -165,8 +212,6 @@ impl WorkspaceAttachedWorkdirSession {
|
|||||||
Arc::new(Self {
|
Arc::new(Self {
|
||||||
client,
|
client,
|
||||||
workdir: Workdir::new("workspace-attachment"),
|
workdir: Workdir::new("workspace-attachment"),
|
||||||
expected_session_fence: None,
|
|
||||||
delegations: Vec::new(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,16 +228,13 @@ impl WorkspaceAttachedWorkdirSession {
|
|||||||
"/api/w/{}/workers/self/workdir-session/operations",
|
"/api/w/{}/workers/self/workdir-session/operations",
|
||||||
encode_path_segment(workspace_id)
|
encode_path_segment(workspace_id)
|
||||||
),
|
),
|
||||||
serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest {
|
serde_json::to_string(&WorkspaceWorkdirSessionOperationRequest { operation }).map_err(
|
||||||
expected_session_fence: self.expected_session_fence.clone(),
|
|error| {
|
||||||
delegations: self.delegations.clone(),
|
WorkdirError::Transport(format!(
|
||||||
operation,
|
"failed to encode Workspace Workdir operation: {error}"
|
||||||
})
|
))
|
||||||
.map_err(|error| {
|
},
|
||||||
WorkdirError::Transport(format!(
|
)?,
|
||||||
"failed to encode Workspace Workdir operation: {error}"
|
|
||||||
))
|
|
||||||
})?,
|
|
||||||
);
|
);
|
||||||
let response = self
|
let response = self
|
||||||
.client
|
.client
|
||||||
@@ -241,59 +283,6 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
|||||||
WorkdirSessionCapabilities::ALL
|
WorkdirSessionCapabilities::ALL
|
||||||
}
|
}
|
||||||
|
|
||||||
fn transports_delegation_context(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn capture_delegation_source(
|
|
||||||
&self,
|
|
||||||
request: &workdir::WorkdirDelegationRequest,
|
|
||||||
) -> Result<WorkdirSessionHandle, WorkdirError> {
|
|
||||||
let expected_session_fence = if let Some(fence) = &self.expected_session_fence {
|
|
||||||
fence.clone()
|
|
||||||
} else {
|
|
||||||
let workspace_id = self.client.workspace_id().ok_or_else(|| {
|
|
||||||
WorkdirError::Unavailable("Workspace identity is unavailable".to_string())
|
|
||||||
})?;
|
|
||||||
let response = self
|
|
||||||
.client
|
|
||||||
.execute(WorkspaceRequest {
|
|
||||||
method: WorkspaceRequestMethod::Get,
|
|
||||||
path: format!(
|
|
||||||
"/api/w/{}/workers/self/workdir-session/fence",
|
|
||||||
encode_path_segment(workspace_id)
|
|
||||||
),
|
|
||||||
body: None,
|
|
||||||
})
|
|
||||||
.map_err(|error| {
|
|
||||||
WorkdirError::Unavailable(format!(
|
|
||||||
"failed to capture Workdir attachment fence: {error}"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
let fence: WorkspaceWorkdirSessionFence = serde_json::from_str(&response.body)
|
|
||||||
.map_err(|error| {
|
|
||||||
WorkdirError::Unavailable(format!(
|
|
||||||
"invalid Workdir attachment fence response: {error}"
|
|
||||||
))
|
|
||||||
})?;
|
|
||||||
fence.value
|
|
||||||
};
|
|
||||||
let mut delegations = self.delegations.clone();
|
|
||||||
delegations.push(request.clone());
|
|
||||||
let candidate = Arc::new(Self {
|
|
||||||
client: self.client.clone(),
|
|
||||||
workdir: self.workdir.clone(),
|
|
||||||
expected_session_fence: Some(expected_session_fence),
|
|
||||||
delegations,
|
|
||||||
});
|
|
||||||
candidate
|
|
||||||
.stat(StatRequest {
|
|
||||||
path: workdir::WorkdirPath::new("").expect("empty Workdir path is valid"),
|
|
||||||
})
|
|
||||||
.await?;
|
|
||||||
Ok(candidate)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
async fn stat(&self, request: StatRequest) -> Result<StatResult, WorkdirError> {
|
||||||
match self.operate(WorkdirSessionOperation::Stat(request))? {
|
match self.operate(WorkdirSessionOperation::Stat(request))? {
|
||||||
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
WorkdirSessionOperationResult::Stat(result) => Ok(result),
|
||||||
@@ -387,7 +376,21 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession {
|
|||||||
|
|
||||||
impl WorkspaceHttpWorkdirBackend {
|
impl WorkspaceHttpWorkdirBackend {
|
||||||
fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
fn new(client: Arc<dyn WorkspaceClient>) -> Self {
|
||||||
Self { client }
|
Self {
|
||||||
|
client,
|
||||||
|
before_workdir_release: None,
|
||||||
|
after_workdir_attach: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_child_lifecycle(
|
||||||
|
mut self,
|
||||||
|
before_workdir_release: Option<BeforeWorkdirRelease>,
|
||||||
|
after_workdir_attach: Option<AfterWorkdirAttach>,
|
||||||
|
) -> Self {
|
||||||
|
self.before_workdir_release = before_workdir_release;
|
||||||
|
self.after_workdir_attach = after_workdir_attach;
|
||||||
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_id(&self) -> Result<&str, ToolError> {
|
fn workspace_id(&self) -> Result<&str, ToolError> {
|
||||||
@@ -565,11 +568,26 @@ impl Tool for WorkspaceHttpWorkdirTool {
|
|||||||
parse_input::<WorkdirCreateInput>(input_json)?,
|
parse_input::<WorkdirCreateInput>(input_json)?,
|
||||||
ctx.call_id.to_string(),
|
ctx.call_id.to_string(),
|
||||||
),
|
),
|
||||||
WorkdirOperation::Attach => self
|
WorkdirOperation::Attach => {
|
||||||
.backend
|
let result = self
|
||||||
.attach(parse_input::<WorkdirAttachInput>(input_json)?),
|
.backend
|
||||||
|
.attach(parse_input::<WorkdirAttachInput>(input_json)?);
|
||||||
|
if result.is_ok()
|
||||||
|
&& let Some(after_attach) = &self.backend.after_workdir_attach
|
||||||
|
{
|
||||||
|
after_attach();
|
||||||
|
}
|
||||||
|
result
|
||||||
|
}
|
||||||
WorkdirOperation::Detach => {
|
WorkdirOperation::Detach => {
|
||||||
let _input = parse_input::<WorkdirDetachInput>(input_json)?;
|
let _input = parse_input::<WorkdirDetachInput>(input_json)?;
|
||||||
|
if let Some(before_release) = &self.backend.before_workdir_release {
|
||||||
|
before_release().await.map_err(|error| {
|
||||||
|
ToolError::ExecutionFailed(format!(
|
||||||
|
"stop Internal SubWorkers before Workdir detach: {error}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
self.backend.detach()
|
self.backend.detach()
|
||||||
}
|
}
|
||||||
WorkdirOperation::Delete => self
|
WorkdirOperation::Delete => self
|
||||||
@@ -765,6 +783,7 @@ struct WorkdirDeleteInput {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::feature::{FeatureModule, FeatureRegistryBuilder};
|
use crate::feature::{FeatureModule, FeatureRegistryBuilder};
|
||||||
@@ -1155,6 +1174,7 @@ mod tests {
|
|||||||
command: "true".to_string(),
|
command: "true".to_string(),
|
||||||
timeout_secs: 120,
|
timeout_secs: 120,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
spill_dir: Some("/worker-local/bash-output".into()),
|
spill_dir: Some("/worker-local/bash-output".into()),
|
||||||
tool_call_id: Some("call-1".to_string()),
|
tool_call_id: Some("call-1".to_string()),
|
||||||
})
|
})
|
||||||
@@ -1178,83 +1198,6 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn delegated_attached_session_carries_captured_fence_on_operations() {
|
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
|
||||||
response(json!({"value": "attachment-fence"})),
|
|
||||||
response(json!({
|
|
||||||
"operation": "stat",
|
|
||||||
"result": {"path": "", "kind": "directory", "size": 0}
|
|
||||||
})),
|
|
||||||
response(json!({
|
|
||||||
"operation": "stat",
|
|
||||||
"result": {"path": "visible.txt", "kind": "file", "size": 8}
|
|
||||||
})),
|
|
||||||
]));
|
|
||||||
let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle(
|
|
||||||
client.clone(),
|
|
||||||
));
|
|
||||||
let delegation = parent
|
|
||||||
.delegate(workdir::WorkdirDelegationRequest {
|
|
||||||
rules: vec![workdir::WorkdirDelegationRule {
|
|
||||||
target: workdir::WorkdirPath::new("").unwrap(),
|
|
||||||
permission: workdir::WorkdirDelegationPermission::Read,
|
|
||||||
recursive: false,
|
|
||||||
}],
|
|
||||||
cwd: workdir::WorkdirPath::new("").unwrap(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
delegation
|
|
||||||
.scoped_session
|
|
||||||
.stat(StatRequest {
|
|
||||||
path: workdir::WorkdirPath::new("visible.txt").unwrap(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let requests = client.requests();
|
|
||||||
assert_eq!(requests.len(), 3);
|
|
||||||
assert_eq!(
|
|
||||||
requests[0].path,
|
|
||||||
"/api/w/workspace%2Ftest/workers/self/workdir-session/fence"
|
|
||||||
);
|
|
||||||
let body: serde_json::Value =
|
|
||||||
serde_json::from_str(requests[2].body.as_deref().unwrap()).unwrap();
|
|
||||||
assert_eq!(body["expected_session_fence"], "attachment-fence");
|
|
||||||
assert_eq!(body["operation"]["operation"], "stat");
|
|
||||||
assert_eq!(body["delegations"][0]["rules"][0]["target"], "");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn attached_provider_rejection_happens_before_delegation_is_returned() {
|
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
|
||||||
response(json!({"value": "attachment-fence"})),
|
|
||||||
response(json!({"error": "provider rejected delegated write target"})),
|
|
||||||
]));
|
|
||||||
let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle(
|
|
||||||
client.clone(),
|
|
||||||
));
|
|
||||||
let result = parent
|
|
||||||
.delegate(workdir::WorkdirDelegationRequest {
|
|
||||||
rules: vec![workdir::WorkdirDelegationRule {
|
|
||||||
target: workdir::WorkdirPath::new("linked-target").unwrap(),
|
|
||||||
permission: workdir::WorkdirDelegationPermission::Write,
|
|
||||||
recursive: true,
|
|
||||||
}],
|
|
||||||
cwd: workdir::WorkdirPath::new("linked-target").unwrap(),
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
assert!(result.is_err(), "provider rejection must fail before lease");
|
|
||||||
let requests = client.requests();
|
|
||||||
assert_eq!(requests.len(), 2);
|
|
||||||
let validation: serde_json::Value =
|
|
||||||
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
|
|
||||||
assert_eq!(validation["operation"]["operation"], "stat");
|
|
||||||
assert_eq!(validation["delegations"].as_array().unwrap().len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn attached_session_preserves_typed_provider_validation_error() {
|
async fn attached_session_preserves_typed_provider_validation_error() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![error_response(
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![error_response(
|
||||||
@@ -1298,73 +1241,52 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn nested_attached_session_preserves_full_delegation_chain() {
|
async fn scoped_broker_operations_carry_no_child_context() {
|
||||||
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![
|
||||||
response(json!({"value": "attachment-fence"})),
|
|
||||||
response(json!({
|
response(json!({
|
||||||
"operation": "stat",
|
"operation": "stat",
|
||||||
"result": {"path": "", "kind": "directory", "size": 0}
|
"result": {"path": "visible.txt", "kind": "file", "size": 8}
|
||||||
})),
|
})),
|
||||||
response(json!({
|
response(json!({
|
||||||
"operation": "stat",
|
"operation": "stat",
|
||||||
"result": {"path": "nested", "kind": "directory", "size": 0}
|
"result": {"path": "visible.txt", "kind": "file", "size": 8}
|
||||||
})),
|
|
||||||
response(json!({
|
|
||||||
"operation": "stat",
|
|
||||||
"result": {"path": "nested/file", "kind": "file", "size": 1}
|
|
||||||
})),
|
})),
|
||||||
]));
|
]));
|
||||||
let parent = workdir::delegation_capable_session(WorkspaceAttachedWorkdirSession::handle(
|
let broker = workdir::WorkdirToolBroker::new(WorkspaceAttachedWorkdirSession::handle(
|
||||||
client.clone(),
|
client.clone(),
|
||||||
));
|
));
|
||||||
let outer = parent
|
let scoped = broker
|
||||||
.delegate(workdir::WorkdirDelegationRequest {
|
.scope(workdir::WorkdirToolScope {
|
||||||
rules: vec![workdir::WorkdirDelegationRule {
|
rules: vec![workdir::WorkdirToolScopeRule {
|
||||||
target: workdir::WorkdirPath::new("").unwrap(),
|
target: workdir::WorkdirPath::new("").unwrap(),
|
||||||
permission: workdir::WorkdirDelegationPermission::Read,
|
permission: workdir::WorkdirToolScopePermission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
}],
|
}],
|
||||||
cwd: workdir::WorkdirPath::new("").unwrap(),
|
cwd: workdir::WorkdirPath::new("").unwrap(),
|
||||||
|
command: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let nested = outer
|
scoped
|
||||||
.scoped_session
|
|
||||||
.delegate(workdir::WorkdirDelegationRequest {
|
|
||||||
rules: vec![workdir::WorkdirDelegationRule {
|
|
||||||
target: workdir::WorkdirPath::new("nested").unwrap(),
|
|
||||||
permission: workdir::WorkdirDelegationPermission::Read,
|
|
||||||
recursive: true,
|
|
||||||
}],
|
|
||||||
cwd: workdir::WorkdirPath::new("nested").unwrap(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
nested
|
|
||||||
.scoped_session
|
|
||||||
.stat(StatRequest {
|
.stat(StatRequest {
|
||||||
path: workdir::WorkdirPath::new("file").unwrap(),
|
path: workdir::WorkdirPath::new("visible.txt").unwrap(),
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let requests = client.requests();
|
let requests = client.requests();
|
||||||
assert_eq!(requests.len(), 4);
|
assert_eq!(requests.len(), 2);
|
||||||
let outer_validation: serde_json::Value =
|
for request in requests {
|
||||||
serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap();
|
assert_eq!(
|
||||||
let nested_validation: serde_json::Value =
|
request.path,
|
||||||
serde_json::from_str(requests[2].body.as_deref().unwrap()).unwrap();
|
"/api/w/workspace%2Ftest/workers/self/workdir-session/operations"
|
||||||
assert_eq!(outer_validation["delegations"].as_array().unwrap().len(), 1);
|
);
|
||||||
assert_eq!(
|
let body: serde_json::Value =
|
||||||
nested_validation["delegations"].as_array().unwrap().len(),
|
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||||
2
|
assert!(body.get("delegations").is_none());
|
||||||
);
|
assert!(body.get("child").is_none());
|
||||||
let body: serde_json::Value =
|
assert!(body.get("expected_session_fence").is_none());
|
||||||
serde_json::from_str(requests[3].body.as_deref().unwrap()).unwrap();
|
}
|
||||||
assert_eq!(body["delegations"].as_array().unwrap().len(), 2);
|
|
||||||
assert_eq!(body["delegations"][0]["rules"][0]["target"], "");
|
|
||||||
assert_eq!(body["delegations"][1]["rules"][0]["target"], "nested");
|
|
||||||
assert_eq!(body["operation"]["request"]["path"], "file");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1416,4 +1338,86 @@ mod tests {
|
|||||||
assert!(client.requests().is_empty());
|
assert!(client.requests().is_empty());
|
||||||
assert!(parse_input::<WorkdirListInput>(r#"{"path":"/tmp"}"#).is_err());
|
assert!(parse_input::<WorkdirListInput>(r#"{"path":"/tmp"}"#).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn detach_stops_internal_subworkers_before_backend_release() {
|
||||||
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||||
|
"workspace_id": "workspace/test",
|
||||||
|
"workdir_id": "wd-attached",
|
||||||
|
"attached": false
|
||||||
|
}))]));
|
||||||
|
let cleanup_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let cleanup_calls_for_guard = cleanup_calls.clone();
|
||||||
|
let before_release: BeforeWorkdirRelease = Arc::new(move || {
|
||||||
|
let cleanup_calls = cleanup_calls_for_guard.clone();
|
||||||
|
Box::pin(async move {
|
||||||
|
cleanup_calls.fetch_add(1, Ordering::SeqCst);
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let tool = WorkspaceHttpWorkdirTool {
|
||||||
|
backend: WorkspaceHttpWorkdirBackend::new(client.clone())
|
||||||
|
.with_child_lifecycle(Some(before_release), None),
|
||||||
|
operation: WorkdirOperation::Detach,
|
||||||
|
};
|
||||||
|
|
||||||
|
tool.execute("{}", ToolExecutionContext::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(cleanup_calls.load(Ordering::SeqCst), 1);
|
||||||
|
assert_eq!(client.requests().len(), 1);
|
||||||
|
assert_eq!(
|
||||||
|
client.requests()[0].path,
|
||||||
|
"/api/w/workspace%2Ftest/workers/self/workdir-attachment"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn detach_does_not_release_backend_when_child_cleanup_fails() {
|
||||||
|
let client = Arc::new(RecordingWorkspaceClient::new(Vec::new()));
|
||||||
|
let before_release: BeforeWorkdirRelease =
|
||||||
|
Arc::new(|| Box::pin(async { Err(std::io::Error::other("child cleanup failed")) }));
|
||||||
|
let tool = WorkspaceHttpWorkdirTool {
|
||||||
|
backend: WorkspaceHttpWorkdirBackend::new(client.clone())
|
||||||
|
.with_child_lifecycle(Some(before_release), None),
|
||||||
|
operation: WorkdirOperation::Detach,
|
||||||
|
};
|
||||||
|
|
||||||
|
let error = tool
|
||||||
|
.execute("{}", ToolExecutionContext::default())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
|
||||||
|
assert!(error.to_string().contains("stop Internal SubWorkers"));
|
||||||
|
assert!(client.requests().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn successful_attach_reopens_internal_subworker_admission() {
|
||||||
|
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
|
||||||
|
"workspace_id": "workspace/test",
|
||||||
|
"workdir_id": "wd-attached",
|
||||||
|
"attached": true
|
||||||
|
}))]));
|
||||||
|
let reopen_calls = Arc::new(AtomicUsize::new(0));
|
||||||
|
let reopen_calls_for_hook = reopen_calls.clone();
|
||||||
|
let after_attach: AfterWorkdirAttach = Arc::new(move || {
|
||||||
|
reopen_calls_for_hook.fetch_add(1, Ordering::SeqCst);
|
||||||
|
});
|
||||||
|
let tool = WorkspaceHttpWorkdirTool {
|
||||||
|
backend: WorkspaceHttpWorkdirBackend::new(client)
|
||||||
|
.with_child_lifecycle(None, Some(after_attach)),
|
||||||
|
operation: WorkdirOperation::Attach,
|
||||||
|
};
|
||||||
|
|
||||||
|
tool.execute(
|
||||||
|
r#"{"workdir_id":"wd-attached"}"#,
|
||||||
|
ToolExecutionContext::default(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(reopen_calls.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -744,7 +744,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
|
|||||||
}
|
}
|
||||||
|
|
||||||
Box::pin(prepare_internal_worker_session(
|
Box::pin(prepare_internal_worker_session(
|
||||||
worker, store, visibility, None, None,
|
worker, store, visibility, None, None, None,
|
||||||
))
|
))
|
||||||
.await
|
.await
|
||||||
})
|
})
|
||||||
@@ -781,13 +781,16 @@ pub(crate) async fn prepare_internal_worker_session(
|
|||||||
visibility: InternalWorkerVisibility,
|
visibility: InternalWorkerVisibility,
|
||||||
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
|
child_registry: Option<Arc<SpawnedWorkerRegistry>>,
|
||||||
on_turn_end: Option<Arc<dyn Fn(InternalWorkerSessionStatus) + Send + Sync>>,
|
on_turn_end: Option<Arc<dyn Fn(InternalWorkerSessionStatus) + Send + Sync>>,
|
||||||
|
command_event_broker: Option<workdir::WorkdirToolBroker>,
|
||||||
) -> Result<InternalWorkerSessionHandle, InternalWorkerSessionError> {
|
) -> Result<InternalWorkerSessionHandle, InternalWorkerSessionError> {
|
||||||
let (event_tx, _event_rx) = broadcast::channel(256);
|
let (event_tx, _event_rx) = broadcast::channel(256);
|
||||||
let sink = worker.sink();
|
let sink = worker.sink();
|
||||||
spawn_internal_log_event_bridge(sink.clone(), event_tx.clone());
|
spawn_internal_log_event_bridge(sink.clone(), event_tx.clone());
|
||||||
let alerter = Alerter::new(event_tx.clone());
|
let alerter = Alerter::new(event_tx.clone());
|
||||||
let in_flight = InFlightEvents::new(event_tx.clone());
|
let in_flight = InFlightEvents::new(event_tx.clone());
|
||||||
if let Some(session) = worker.workdir_session() {
|
if let Some(broker) = command_event_broker.as_ref() {
|
||||||
|
wire_workdir_command_events(&broker.tool_session(), &in_flight);
|
||||||
|
} else if let Some(session) = worker.workdir_session() {
|
||||||
wire_workdir_command_events(session, &in_flight);
|
wire_workdir_command_events(session, &in_flight);
|
||||||
}
|
}
|
||||||
let actor_in_flight = in_flight.clone();
|
let actor_in_flight = in_flight.clone();
|
||||||
@@ -918,6 +921,7 @@ pub(crate) async fn spawn_prepared_internal_worker_session(
|
|||||||
InternalWorkerVisibility::ServicePrivate,
|
InternalWorkerVisibility::ServicePrivate,
|
||||||
None,
|
None,
|
||||||
on_turn_end,
|
on_turn_end,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
handle.send(input).await?;
|
handle.send(input).await?;
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use std::collections::{BTreeMap, HashSet};
|
|||||||
use std::io;
|
use std::io;
|
||||||
use std::sync::{
|
use std::sync::{
|
||||||
Arc, Mutex,
|
Arc, Mutex,
|
||||||
atomic::{AtomicBool, AtomicU64, Ordering},
|
atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering},
|
||||||
};
|
};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
@@ -23,9 +23,9 @@ use protocol::{Event, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnaps
|
|||||||
use session_store::{
|
use session_store::{
|
||||||
LoggedItem, WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError,
|
LoggedItem, WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError,
|
||||||
};
|
};
|
||||||
use tokio::sync::broadcast;
|
use tokio::sync::{Notify, broadcast};
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
use workdir::WorkdirDelegation;
|
use workdir::WorkdirScopeLease;
|
||||||
|
|
||||||
use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibility};
|
use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibility};
|
||||||
use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
|
use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord};
|
||||||
@@ -68,10 +68,11 @@ pub(crate) struct SubWorkerStopSummary {
|
|||||||
pub(crate) struct InternalSpawnedWorkerRecord {
|
pub(crate) struct InternalSpawnedWorkerRecord {
|
||||||
pub worker_name: String,
|
pub worker_name: String,
|
||||||
pub scope_delegated: Vec<ScopeRule>,
|
pub scope_delegated: Vec<ScopeRule>,
|
||||||
pub workdir_delegation: Arc<WorkdirDelegation>,
|
pub workdir_tool_scope: Arc<WorkdirScopeLease>,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub installed_tools: Arc<[String]>,
|
pub installed_tools: Arc<[String]>,
|
||||||
pub session: InternalWorkerSessionHandle,
|
pub session: InternalWorkerSessionHandle,
|
||||||
|
pub child_registry: Arc<SpawnedWorkerRegistry>,
|
||||||
change_tracker: Option<tools::Tracker>,
|
change_tracker: Option<tools::Tracker>,
|
||||||
started_at: Instant,
|
started_at: Instant,
|
||||||
stop_lock: Arc<tokio::sync::Mutex<()>>,
|
stop_lock: Arc<tokio::sync::Mutex<()>>,
|
||||||
@@ -86,18 +87,20 @@ impl InternalSpawnedWorkerRecord {
|
|||||||
pub(crate) fn new(
|
pub(crate) fn new(
|
||||||
worker_name: String,
|
worker_name: String,
|
||||||
scope_delegated: Vec<ScopeRule>,
|
scope_delegated: Vec<ScopeRule>,
|
||||||
workdir_delegation: WorkdirDelegation,
|
workdir_tool_scope: WorkdirScopeLease,
|
||||||
#[cfg(test)] installed_tools: Vec<String>,
|
#[cfg(test)] installed_tools: Vec<String>,
|
||||||
session: InternalWorkerSessionHandle,
|
session: InternalWorkerSessionHandle,
|
||||||
|
child_registry: Arc<SpawnedWorkerRegistry>,
|
||||||
change_tracker: Option<tools::Tracker>,
|
change_tracker: Option<tools::Tracker>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
worker_name,
|
worker_name,
|
||||||
scope_delegated,
|
scope_delegated,
|
||||||
workdir_delegation: Arc::new(workdir_delegation),
|
workdir_tool_scope: Arc::new(workdir_tool_scope),
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
installed_tools: installed_tools.into(),
|
installed_tools: installed_tools.into(),
|
||||||
session,
|
session,
|
||||||
|
child_registry,
|
||||||
change_tracker,
|
change_tracker,
|
||||||
started_at: Instant::now(),
|
started_at: Instant::now(),
|
||||||
stop_lock: Arc::new(tokio::sync::Mutex::new(())),
|
stop_lock: Arc::new(tokio::sync::Mutex::new(())),
|
||||||
@@ -235,18 +238,56 @@ pub(crate) struct InternalSpawnReservation {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl InternalSpawnReservation {
|
impl InternalSpawnReservation {
|
||||||
pub(crate) fn commit(mut self, record: InternalSpawnedWorkerRecord) -> io::Result<()> {
|
pub(crate) async fn commit(mut self, record: InternalSpawnedWorkerRecord) -> io::Result<()> {
|
||||||
if record.worker_name != self.worker_name {
|
let rejection = if record.worker_name != self.worker_name {
|
||||||
return Err(io::Error::new(
|
Some(io::Error::new(
|
||||||
io::ErrorKind::InvalidInput,
|
io::ErrorKind::InvalidInput,
|
||||||
"internal SubWorker reservation name does not match record name",
|
"internal SubWorker reservation name does not match record name",
|
||||||
));
|
))
|
||||||
|
} else {
|
||||||
|
match self.registry.internal_records.lock() {
|
||||||
|
Ok(mut records) => {
|
||||||
|
if self.registry.internal_shutting_down.load(Ordering::Acquire) {
|
||||||
|
Some(io::Error::new(
|
||||||
|
io::ErrorKind::Interrupted,
|
||||||
|
"internal SubWorker registry is shutting down",
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
records.push(record.clone());
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => Some(io::Error::other(
|
||||||
|
"internal spawned-worker registry lock poisoned",
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if let Some(error) = rejection {
|
||||||
|
let mut cleanup_failures = Vec::new();
|
||||||
|
if let Err(cleanup) = record.session.stop().await {
|
||||||
|
cleanup_failures.push(format!("stop rejected Internal SubWorker: {cleanup}"));
|
||||||
|
}
|
||||||
|
if let Err(cleanup) = Box::pin(record.child_registry.shutdown_internal()).await {
|
||||||
|
cleanup_failures.push(format!(
|
||||||
|
"stop rejected Internal SubWorker descendants: {cleanup}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if let Err(cleanup) = record.workdir_tool_scope.close().await {
|
||||||
|
cleanup_failures.push(format!(
|
||||||
|
"close rejected Internal SubWorker Workdir tools: {cleanup}"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if cleanup_failures.is_empty() {
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
self.registry
|
||||||
|
.internal_spawn_cleanup_failed
|
||||||
|
.store(true, Ordering::Release);
|
||||||
|
return Err(io::Error::other(format!(
|
||||||
|
"{error}; {}",
|
||||||
|
cleanup_failures.join("; ")
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
self.registry
|
|
||||||
.internal_records
|
|
||||||
.lock()
|
|
||||||
.map_err(|_| io::Error::other("internal spawned-worker registry lock poisoned"))?
|
|
||||||
.push(record.clone());
|
|
||||||
self.registry.start_protocol_forwarding(record);
|
self.registry.start_protocol_forwarding(record);
|
||||||
self.committed = true;
|
self.committed = true;
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -260,6 +301,10 @@ impl Drop for InternalSpawnReservation {
|
|||||||
names.remove(&self.worker_name);
|
names.remove(&self.worker_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
self.registry
|
||||||
|
.pending_internal_spawns
|
||||||
|
.fetch_sub(1, Ordering::AcqRel);
|
||||||
|
self.registry.pending_internal_notify.notify_waiters();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -267,6 +312,10 @@ pub struct SpawnedWorkerRegistry {
|
|||||||
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
|
internal_records: std::sync::Mutex<Vec<InternalSpawnedWorkerRecord>>,
|
||||||
service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>,
|
service_records: std::sync::Mutex<Vec<InternalServiceWorkerRecord>>,
|
||||||
internal_names: std::sync::Mutex<HashSet<String>>,
|
internal_names: std::sync::Mutex<HashSet<String>>,
|
||||||
|
internal_shutting_down: AtomicBool,
|
||||||
|
pending_internal_spawns: AtomicUsize,
|
||||||
|
pending_internal_notify: Notify,
|
||||||
|
internal_spawn_cleanup_failed: AtomicBool,
|
||||||
parent_scope: Option<SharedScope>,
|
parent_scope: Option<SharedScope>,
|
||||||
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
parent_protocol: Mutex<Option<(broadcast::Sender<Event>, String)>>,
|
||||||
}
|
}
|
||||||
@@ -283,6 +332,10 @@ impl SpawnedWorkerRegistry {
|
|||||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
service_records: std::sync::Mutex::new(Vec::new()),
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
|
internal_shutting_down: AtomicBool::new(false),
|
||||||
|
pending_internal_spawns: AtomicUsize::new(0),
|
||||||
|
pending_internal_notify: Notify::new(),
|
||||||
|
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||||
parent_scope: None,
|
parent_scope: None,
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
})
|
})
|
||||||
@@ -294,6 +347,10 @@ impl SpawnedWorkerRegistry {
|
|||||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
service_records: std::sync::Mutex::new(Vec::new()),
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
|
internal_shutting_down: AtomicBool::new(false),
|
||||||
|
pending_internal_spawns: AtomicUsize::new(0),
|
||||||
|
pending_internal_notify: Notify::new(),
|
||||||
|
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||||
parent_scope: None,
|
parent_scope: None,
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
})
|
})
|
||||||
@@ -304,6 +361,10 @@ impl SpawnedWorkerRegistry {
|
|||||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
service_records: std::sync::Mutex::new(Vec::new()),
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
|
internal_shutting_down: AtomicBool::new(false),
|
||||||
|
pending_internal_spawns: AtomicUsize::new(0),
|
||||||
|
pending_internal_notify: Notify::new(),
|
||||||
|
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||||
parent_scope: Some(parent_scope),
|
parent_scope: Some(parent_scope),
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
})
|
})
|
||||||
@@ -383,6 +444,10 @@ impl SpawnedWorkerRegistry {
|
|||||||
internal_records: std::sync::Mutex::new(Vec::new()),
|
internal_records: std::sync::Mutex::new(Vec::new()),
|
||||||
service_records: std::sync::Mutex::new(Vec::new()),
|
service_records: std::sync::Mutex::new(Vec::new()),
|
||||||
internal_names: std::sync::Mutex::new(HashSet::new()),
|
internal_names: std::sync::Mutex::new(HashSet::new()),
|
||||||
|
internal_shutting_down: AtomicBool::new(false),
|
||||||
|
pending_internal_spawns: AtomicUsize::new(0),
|
||||||
|
pending_internal_notify: Notify::new(),
|
||||||
|
internal_spawn_cleanup_failed: AtomicBool::new(false),
|
||||||
parent_scope,
|
parent_scope,
|
||||||
parent_protocol: Mutex::new(None),
|
parent_protocol: Mutex::new(None),
|
||||||
}),
|
}),
|
||||||
@@ -394,6 +459,16 @@ impl SpawnedWorkerRegistry {
|
|||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
worker_name: String,
|
worker_name: String,
|
||||||
) -> io::Result<InternalSpawnReservation> {
|
) -> io::Result<InternalSpawnReservation> {
|
||||||
|
let records = self
|
||||||
|
.internal_records
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("internal Worker registry lock poisoned"))?;
|
||||||
|
if self.internal_shutting_down.load(Ordering::Acquire) {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::Interrupted,
|
||||||
|
"internal SubWorker registry is shutting down",
|
||||||
|
));
|
||||||
|
}
|
||||||
let mut names = self
|
let mut names = self
|
||||||
.internal_names
|
.internal_names
|
||||||
.lock()
|
.lock()
|
||||||
@@ -404,7 +479,9 @@ impl SpawnedWorkerRegistry {
|
|||||||
format!("spawned worker `{worker_name}` is already registered"),
|
format!("spawned worker `{worker_name}` is already registered"),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
self.pending_internal_spawns.fetch_add(1, Ordering::AcqRel);
|
||||||
drop(names);
|
drop(names);
|
||||||
|
drop(records);
|
||||||
Ok(InternalSpawnReservation {
|
Ok(InternalSpawnReservation {
|
||||||
registry: Arc::clone(self),
|
registry: Arc::clone(self),
|
||||||
worker_name,
|
worker_name,
|
||||||
@@ -679,18 +756,11 @@ impl SpawnedWorkerRegistry {
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn reclaim_internal_scope(&self, worker_name: &str) -> io::Result<bool> {
|
|
||||||
let record = self.get_internal(worker_name).ok_or_else(|| {
|
|
||||||
io::Error::new(io::ErrorKind::NotFound, "internal SubWorker not found")
|
|
||||||
})?;
|
|
||||||
self.reclaim_record_scope(&record)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn reclaim_record_scope(&self, record: &InternalSpawnedWorkerRecord) -> io::Result<bool> {
|
fn reclaim_record_scope(&self, record: &InternalSpawnedWorkerRecord) -> io::Result<bool> {
|
||||||
if !record.claim_scope_reclaim() {
|
if !record.claim_scope_reclaim() {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
record.workdir_delegation.release();
|
record.workdir_tool_scope.revoke();
|
||||||
let result = if let Some(parent_scope) = &self.parent_scope {
|
let result = if let Some(parent_scope) = &self.parent_scope {
|
||||||
parent_scope
|
parent_scope
|
||||||
.update(|current| current.with_removed_deny_rules(delegated_write_rules(record)))
|
.update(|current| current.with_removed_deny_rules(delegated_write_rules(record)))
|
||||||
@@ -705,6 +775,58 @@ impl SpawnedWorkerRegistry {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn close_internal_scope(&self, name: &str) -> io::Result<bool> {
|
||||||
|
let Some(record) = self.get_internal(name) else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
Box::pin(record.child_registry.shutdown_internal()).await?;
|
||||||
|
record
|
||||||
|
.workdir_tool_scope
|
||||||
|
.close()
|
||||||
|
.await
|
||||||
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
|
self.reclaim_record_scope(&record)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn shutdown_internal(&self) -> io::Result<()> {
|
||||||
|
let names = {
|
||||||
|
let records = self
|
||||||
|
.internal_records
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| io::Error::other("internal Worker registry lock poisoned"))?;
|
||||||
|
self.internal_shutting_down.store(true, Ordering::Release);
|
||||||
|
records
|
||||||
|
.iter()
|
||||||
|
.map(|record| record.worker_name.clone())
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
let notified = self.pending_internal_notify.notified();
|
||||||
|
if self.pending_internal_spawns.load(Ordering::Acquire) == 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
notified.await;
|
||||||
|
}
|
||||||
|
let mut first_error = None;
|
||||||
|
for name in names {
|
||||||
|
if let Err(error) = self.remove_internal(&name).await {
|
||||||
|
first_error.get_or_insert(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if first_error.is_none() && self.internal_spawn_cleanup_failed.load(Ordering::Acquire) {
|
||||||
|
first_error = Some(io::Error::other(
|
||||||
|
"an in-flight Internal SubWorker failed cleanup during shutdown",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
first_error.map_or(Ok(()), Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn reopen_internal(&self) {
|
||||||
|
self.internal_shutting_down.store(false, Ordering::Release);
|
||||||
|
self.internal_spawn_cleanup_failed
|
||||||
|
.store(false, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
/// Stop one direct Internal SubWorker and discard its registry/scope state.
|
/// Stop one direct Internal SubWorker and discard its registry/scope state.
|
||||||
///
|
///
|
||||||
/// The child actor must acknowledge its stop before the registry is removed.
|
/// The child actor must acknowledge its stop before the registry is removed.
|
||||||
@@ -731,6 +853,12 @@ impl SpawnedWorkerRegistry {
|
|||||||
.stop()
|
.stop()
|
||||||
.await
|
.await
|
||||||
.map_err(|error| io::Error::other(error.to_string()))?;
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
|
Box::pin(record.child_registry.shutdown_internal()).await?;
|
||||||
|
record
|
||||||
|
.workdir_tool_scope
|
||||||
|
.close()
|
||||||
|
.await
|
||||||
|
.map_err(|error| io::Error::other(error.to_string()))?;
|
||||||
let summary = record.stop_summary();
|
let summary = record.stop_summary();
|
||||||
self.reclaim_record_scope(&record)?;
|
self.reclaim_record_scope(&record)?;
|
||||||
let removed =
|
let removed =
|
||||||
@@ -966,7 +1094,7 @@ mod tests {
|
|||||||
deny: Vec::new(),
|
deny: Vec::new(),
|
||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let source = workdir::delegation_capable_session(Arc::new(
|
let source = workdir::WorkdirToolBroker::new(Arc::new(
|
||||||
workdir::LocalWorkdirSession::materialized_bound(
|
workdir::LocalWorkdirSession::materialized_bound(
|
||||||
workdir::Workdir::new("registry-test"),
|
workdir::Workdir::new("registry-test"),
|
||||||
root.clone(),
|
root.clone(),
|
||||||
@@ -976,13 +1104,14 @@ mod tests {
|
|||||||
),
|
),
|
||||||
));
|
));
|
||||||
let delegation = source
|
let delegation = source
|
||||||
.delegate(workdir::WorkdirDelegationRequest {
|
.scope(workdir::WorkdirToolScope {
|
||||||
rules: vec![workdir::WorkdirDelegationRule {
|
rules: vec![workdir::WorkdirToolScopeRule {
|
||||||
target: workdir::WorkdirPath::new("").unwrap(),
|
target: workdir::WorkdirPath::new("").unwrap(),
|
||||||
permission: workdir::WorkdirDelegationPermission::Read,
|
permission: workdir::WorkdirToolScopePermission::Read,
|
||||||
recursive: true,
|
recursive: true,
|
||||||
}],
|
}],
|
||||||
cwd: workdir::WorkdirPath::new("").unwrap(),
|
cwd: workdir::WorkdirPath::new("").unwrap(),
|
||||||
|
command: false,
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -993,6 +1122,7 @@ mod tests {
|
|||||||
delegation,
|
delegation,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
session,
|
session,
|
||||||
|
registry(),
|
||||||
None,
|
None,
|
||||||
),
|
),
|
||||||
sender,
|
sender,
|
||||||
@@ -1230,6 +1360,143 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn parent_shutdown_stops_all_internal_workers_before_returning() {
|
||||||
|
let registry = registry();
|
||||||
|
for name in ["first", "second"] {
|
||||||
|
let (record, _events) = record(name, InternalWorkerVisibility::ParentClient).await;
|
||||||
|
record
|
||||||
|
.session
|
||||||
|
.force_status(InternalWorkerSessionStatus::Running);
|
||||||
|
install_record(®istry, record);
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.shutdown_internal().await.unwrap();
|
||||||
|
|
||||||
|
assert!(registry.list_internal().is_empty());
|
||||||
|
assert!(registry.get_internal("first").is_none());
|
||||||
|
assert!(registry.get_internal("second").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shutdown_rejects_new_reservations_until_reopened() {
|
||||||
|
let registry = registry();
|
||||||
|
registry.shutdown_internal().await.unwrap();
|
||||||
|
assert!(registry.reserve_internal_name("late-child".into()).is_err());
|
||||||
|
|
||||||
|
registry.reopen_internal();
|
||||||
|
let reservation = registry.reserve_internal_name("late-child".into()).unwrap();
|
||||||
|
drop(reservation);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn concurrent_commit_and_shutdown_leave_no_live_internal_worker() {
|
||||||
|
let registry = registry();
|
||||||
|
let reservation = registry
|
||||||
|
.reserve_internal_name("racing-child".into())
|
||||||
|
.unwrap();
|
||||||
|
let (record, _events) =
|
||||||
|
record("racing-child", InternalWorkerVisibility::ParentClient).await;
|
||||||
|
let scope = record.workdir_tool_scope.clone();
|
||||||
|
let barrier = Arc::new(tokio::sync::Barrier::new(2));
|
||||||
|
let commit_barrier = barrier.clone();
|
||||||
|
let commit = tokio::spawn(async move {
|
||||||
|
commit_barrier.wait().await;
|
||||||
|
reservation.commit(record).await
|
||||||
|
});
|
||||||
|
let shutdown_registry = registry.clone();
|
||||||
|
let shutdown = tokio::spawn(async move {
|
||||||
|
barrier.wait().await;
|
||||||
|
shutdown_registry.shutdown_internal().await
|
||||||
|
});
|
||||||
|
|
||||||
|
let commit = commit.await.unwrap();
|
||||||
|
shutdown.await.unwrap().unwrap();
|
||||||
|
if let Err(error) = commit {
|
||||||
|
assert_eq!(error.kind(), io::ErrorKind::Interrupted);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert!(registry.list_internal().is_empty());
|
||||||
|
assert!(!scope.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shutdown_fences_a_reservation_that_has_not_committed() {
|
||||||
|
let registry = registry();
|
||||||
|
let reservation = registry
|
||||||
|
.reserve_internal_name("racing-child".into())
|
||||||
|
.unwrap();
|
||||||
|
let (record, _events) =
|
||||||
|
record("racing-child", InternalWorkerVisibility::ParentClient).await;
|
||||||
|
|
||||||
|
let mut shutdown = {
|
||||||
|
let registry = registry.clone();
|
||||||
|
tokio::spawn(async move { registry.shutdown_internal().await })
|
||||||
|
};
|
||||||
|
while !registry.internal_shutting_down.load(Ordering::Acquire) {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(50), &mut shutdown)
|
||||||
|
.await
|
||||||
|
.is_err(),
|
||||||
|
"shutdown must wait for the pending spawn to roll back"
|
||||||
|
);
|
||||||
|
let error = reservation.commit(record).await.unwrap_err();
|
||||||
|
assert_eq!(error.kind(), io::ErrorKind::Interrupted);
|
||||||
|
shutdown.await.unwrap().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rejected_spawn_cleanup_failure_keeps_shutdown_failed_closed() {
|
||||||
|
let registry = registry();
|
||||||
|
let reservation = registry
|
||||||
|
.reserve_internal_name("cleanup-failure".into())
|
||||||
|
.unwrap();
|
||||||
|
let (record, _events) =
|
||||||
|
record("cleanup-failure", InternalWorkerVisibility::ParentClient).await;
|
||||||
|
record.session.force_stop_failure();
|
||||||
|
let shutdown = {
|
||||||
|
let registry = registry.clone();
|
||||||
|
tokio::spawn(async move { registry.shutdown_internal().await })
|
||||||
|
};
|
||||||
|
while !registry.internal_shutting_down.load(Ordering::Acquire) {
|
||||||
|
tokio::task::yield_now().await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let error = reservation.commit(record).await.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
error
|
||||||
|
.to_string()
|
||||||
|
.contains("stop rejected Internal SubWorker")
|
||||||
|
);
|
||||||
|
let shutdown_error = shutdown.await.unwrap().unwrap_err();
|
||||||
|
assert!(
|
||||||
|
shutdown_error
|
||||||
|
.to_string()
|
||||||
|
.contains("failed cleanup during shutdown")
|
||||||
|
);
|
||||||
|
assert!(registry.internal_shutting_down.load(Ordering::Acquire));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn shutdown_recursively_stops_grandchildren_before_parent_scope_release() {
|
||||||
|
let registry = registry();
|
||||||
|
let (child, _child_events) = record("child", InternalWorkerVisibility::ParentClient).await;
|
||||||
|
let child_registry = child.child_registry.clone();
|
||||||
|
let (grandchild, _grandchild_events) =
|
||||||
|
record("grandchild", InternalWorkerVisibility::ParentClient).await;
|
||||||
|
let grandchild_scope = grandchild.workdir_tool_scope.clone();
|
||||||
|
install_record(&child_registry, grandchild);
|
||||||
|
install_record(®istry, child);
|
||||||
|
|
||||||
|
registry.shutdown_internal().await.unwrap();
|
||||||
|
|
||||||
|
assert!(registry.list_internal().is_empty());
|
||||||
|
assert!(child_registry.list_internal().is_empty());
|
||||||
|
assert!(!grandchild_scope.is_active());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn running_worker_is_stopped_before_removal() {
|
async fn running_worker_is_stopped_before_removal() {
|
||||||
let registry = registry();
|
let registry = registry();
|
||||||
|
|||||||
+85
-176
@@ -22,8 +22,7 @@ use manifest::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use workdir::{
|
use workdir::{
|
||||||
WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirDelegationRule, WorkdirPath,
|
WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission, WorkdirToolScopeRule,
|
||||||
WorkdirSessionHandle,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::PromptCatalogSource;
|
use crate::PromptCatalogSource;
|
||||||
@@ -64,6 +63,9 @@ struct SubWorkerSpawnInput {
|
|||||||
/// spawner's explicit delegation authority; direct tool scope alone is not
|
/// spawner's explicit delegation authority; direct tool scope alone is not
|
||||||
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
|
/// sufficient. Omit `recursive` for normal workspace/worktree delegation; it defaults to true.
|
||||||
scope: Vec<ScopeRuleInput>,
|
scope: Vec<ScopeRuleInput>,
|
||||||
|
/// Explicitly grant command execution through the parent-owned Workdir tool broker.
|
||||||
|
#[serde(default)]
|
||||||
|
command: bool,
|
||||||
/// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate.
|
/// Binds an actual read-only builtin Reviewer child to the current Merge Request candidate.
|
||||||
/// Review capability material is generated by the trusted spawn layer.
|
/// Review capability material is generated by the trusted spawn layer.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -284,8 +286,8 @@ pub struct SubWorkerSpawnTool {
|
|||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
/// Directory the spawned SubWorker's tools should use when the LLM did not
|
/// Directory the spawned SubWorker's tools should use when the LLM did not
|
||||||
/// override it. Defaults to the spawner's cwd.
|
/// override it. Defaults to the spawner's cwd.
|
||||||
/// Active provider-backed Workdir session from which child leases are captured.
|
/// Parent-owned broker for scoped Workdir tool execution.
|
||||||
source_workdir_session: Option<WorkdirSessionHandle>,
|
workdir_tool_broker: Option<WorkdirToolBroker>,
|
||||||
/// Parent-owned in-memory registry shared by the five SubWorker tools.
|
/// Parent-owned in-memory registry shared by the five SubWorker tools.
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
/// Spawner's resolved Manifest. `profile = "inherit"` derives the
|
/// Spawner's resolved Manifest. `profile = "inherit"` derives the
|
||||||
@@ -312,7 +314,7 @@ impl SubWorkerSpawnTool {
|
|||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
bash_output_dir: PathBuf,
|
bash_output_dir: PathBuf,
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
source_workdir_session: Option<WorkdirSessionHandle>,
|
workdir_tool_broker: Option<WorkdirToolBroker>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
spawner_manifest: WorkerManifest,
|
spawner_manifest: WorkerManifest,
|
||||||
prompt_loader: PromptCatalogSource,
|
prompt_loader: PromptCatalogSource,
|
||||||
@@ -325,7 +327,7 @@ impl SubWorkerSpawnTool {
|
|||||||
runtime_base,
|
runtime_base,
|
||||||
bash_output_dir,
|
bash_output_dir,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
source_workdir_session,
|
workdir_tool_broker,
|
||||||
registry,
|
registry,
|
||||||
spawner_manifest,
|
spawner_manifest,
|
||||||
prompt_loader,
|
prompt_loader,
|
||||||
@@ -358,6 +360,11 @@ fn validate_reviewer_handoff(input: &SubWorkerSpawnInput) -> Result<(), ToolErro
|
|||||||
"Merge Request Reviewer SubWorkers must include writable delegated scope".to_string(),
|
"Merge Request Reviewer SubWorkers must include writable delegated scope".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
if !input.command {
|
||||||
|
return Err(ToolError::InvalidArgument(
|
||||||
|
"Merge Request Reviewer SubWorkers require an explicit command grant".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,7 +394,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
.reserve_internal_name(input.name.clone())
|
.reserve_internal_name(input.name.clone())
|
||||||
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
.map_err(|error| ToolError::InvalidArgument(error.to_string()))?;
|
||||||
|
|
||||||
let mut workdir_rules = parse_workdir_scope(&input.scope)?;
|
let workdir_rules = parse_workdir_scope(&input.scope)?;
|
||||||
let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name);
|
let child_bash_output_dir = self.bash_output_dir.join("sub-workers").join(&input.name);
|
||||||
tokio::fs::create_dir_all(&child_bash_output_dir)
|
tokio::fs::create_dir_all(&child_bash_output_dir)
|
||||||
.await
|
.await
|
||||||
@@ -397,28 +404,15 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
child_bash_output_dir.display()
|
child_bash_output_dir.display()
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
let source_workdir_session =
|
let workdir_tool_broker = require_workdir_tool_broker(self.workdir_tool_broker.as_ref())?;
|
||||||
require_active_workdir_session(self.source_workdir_session.as_ref())?;
|
let tool_scope = workdir_tool_scope(input.cwd.as_deref(), workdir_rules, input.command)?;
|
||||||
let transports_delegation_context = source_workdir_session.transports_delegation_context();
|
let workdir_scope = workdir_tool_broker
|
||||||
// Provider-transported sessions resolve every delegation rule in the
|
.scope(tool_scope)
|
||||||
// receiving Workdir namespace. The Bash spill directory instead belongs
|
|
||||||
// to this Worker host, so forwarding it would widen the request with a
|
|
||||||
// foreign absolute path and fail the provider's existing scope check.
|
|
||||||
if !transports_delegation_context {
|
|
||||||
workdir_rules.push(WorkdirDelegationRule {
|
|
||||||
target: WorkdirPath::new_scoped(child_bash_output_dir.to_string_lossy())
|
|
||||||
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?,
|
|
||||||
permission: WorkdirDelegationPermission::Read,
|
|
||||||
recursive: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
let delegation_request = workdir_delegation_request(input.cwd.as_deref(), workdir_rules)?;
|
|
||||||
let workdir_delegation = source_workdir_session
|
|
||||||
.delegate(delegation_request)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
ToolError::InvalidArgument(format!("delegate Workdir session: {error}"))
|
ToolError::InvalidArgument(format!("scope parent-owned Workdir tools: {error}"))
|
||||||
})?;
|
})?;
|
||||||
|
let child_workdir_tool_broker = workdir_scope.broker();
|
||||||
|
|
||||||
let spawn_selector =
|
let spawn_selector =
|
||||||
parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| {
|
parse_spawn_profile_selector(input.profile.as_deref()).map_err(|msg| {
|
||||||
@@ -507,7 +501,6 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
|
.map_err(|error| ToolError::ExecutionFailed(format!("build Internal Worker: {error}")))?;
|
||||||
child.bind_workdir_session(Some(workdir_delegation.scoped_session.clone()));
|
|
||||||
child
|
child
|
||||||
.add_scope_rules([ScopeRule {
|
.add_scope_rules([ScopeRule {
|
||||||
target: child_bash_output_dir.clone(),
|
target: child_bash_output_dir.clone(),
|
||||||
@@ -527,6 +520,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
self.runtime_base.clone(),
|
self.runtime_base.clone(),
|
||||||
child_registry.clone(),
|
child_registry.clone(),
|
||||||
None,
|
None,
|
||||||
|
Some(child_workdir_tool_broker.clone()),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
@@ -555,13 +549,16 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
InternalWorkerSessionStatus::Failed | InternalWorkerSessionStatus::Stopped
|
InternalWorkerSessionStatus::Failed | InternalWorkerSessionStatus::Stopped
|
||||||
) {
|
) {
|
||||||
if let Some(registry) = registry.upgrade() {
|
if let Some(registry) = registry.upgrade() {
|
||||||
if let Err(error) = registry.reclaim_internal_scope(&child_name) {
|
let child_name = child_name.clone();
|
||||||
tracing::warn!(
|
tokio::spawn(async move {
|
||||||
child_name,
|
if let Err(error) = registry.close_internal_scope(&child_name).await {
|
||||||
%error,
|
tracing::warn!(
|
||||||
"failed to reclaim delegated scope after Internal SubWorker failure"
|
child_name,
|
||||||
);
|
%error,
|
||||||
}
|
"failed to close parent-owned Workdir tools after Internal SubWorker failure"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let message = format!(
|
let message = format!(
|
||||||
@@ -569,6 +566,7 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
);
|
);
|
||||||
parent_notifications.notify(child_name.clone(), message, true);
|
parent_notifications.notify(child_name.clone(), message, true);
|
||||||
})),
|
})),
|
||||||
|
Some(child_workdir_tool_broker.clone()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let session = session_result.map_err(|error| {
|
let session = session_result.map_err(|error| {
|
||||||
@@ -619,15 +617,19 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
),
|
),
|
||||||
body.to_string(),
|
body.to_string(),
|
||||||
);
|
);
|
||||||
let response = self
|
let response = match self.workspace_context.client().execute(request) {
|
||||||
.workspace_context
|
Ok(response) => response,
|
||||||
.client()
|
Err(error) => {
|
||||||
.execute(request)
|
let _ = session.stop().await;
|
||||||
.map_err(|error| {
|
let _ = workdir_scope.close().await;
|
||||||
ToolError::ExecutionFailed(format!("register review capability: {error}"))
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
})?;
|
"register review capability: {error}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
};
|
||||||
if !response.is_success() {
|
if !response.is_success() {
|
||||||
let _ = session.stop().await;
|
let _ = session.stop().await;
|
||||||
|
let _ = workdir_scope.close().await;
|
||||||
return Err(ToolError::ExecutionFailed(format!(
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
"register review capability failed with status {}: {}",
|
"register review capability failed with status {}: {}",
|
||||||
response.status, response.body
|
response.status, response.body
|
||||||
@@ -638,14 +640,14 @@ impl Tool for SubWorkerSpawnTool {
|
|||||||
let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new(
|
let record = crate::spawn::registry::InternalSpawnedWorkerRecord::new(
|
||||||
input.name.clone(),
|
input.name.clone(),
|
||||||
scope_allow,
|
scope_allow,
|
||||||
workdir_delegation,
|
workdir_scope,
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
installed_tools,
|
installed_tools,
|
||||||
session.clone(),
|
session.clone(),
|
||||||
|
child_registry,
|
||||||
child_change_tracker,
|
child_change_tracker,
|
||||||
);
|
);
|
||||||
if let Err(error) = name_reservation.commit(record) {
|
if let Err(error) = name_reservation.commit(record).await {
|
||||||
let _ = session.stop().await;
|
|
||||||
return Err(ToolError::ExecutionFailed(format!(
|
return Err(ToolError::ExecutionFailed(format!(
|
||||||
"register Internal Worker session: {error}"
|
"register Internal Worker session: {error}"
|
||||||
)));
|
)));
|
||||||
@@ -691,18 +693,18 @@ fn logical_workdir_path(value: &str, field: &str) -> Result<FsPath, ToolError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result<Vec<WorkdirDelegationRule>, ToolError> {
|
fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result<Vec<WorkdirToolScopeRule>, ToolError> {
|
||||||
if rules.is_empty() {
|
if rules.is_empty() {
|
||||||
return Err(ToolError::InvalidArgument("scope must not be empty".into()));
|
return Err(ToolError::InvalidArgument("scope must not be empty".into()));
|
||||||
}
|
}
|
||||||
rules
|
rules
|
||||||
.iter()
|
.iter()
|
||||||
.map(|rule| {
|
.map(|rule| {
|
||||||
Ok(WorkdirDelegationRule {
|
Ok(WorkdirToolScopeRule {
|
||||||
target: logical_workdir_path(&rule.target, "scope.target")?,
|
target: logical_workdir_path(&rule.target, "scope.target")?,
|
||||||
permission: match rule.permission {
|
permission: match rule.permission {
|
||||||
PermissionInput::Read => WorkdirDelegationPermission::Read,
|
PermissionInput::Read => WorkdirToolScopePermission::Read,
|
||||||
PermissionInput::Write => WorkdirDelegationPermission::Write,
|
PermissionInput::Write => WorkdirToolScopePermission::Write,
|
||||||
},
|
},
|
||||||
recursive: rule.recursive,
|
recursive: rule.recursive,
|
||||||
})
|
})
|
||||||
@@ -710,22 +712,24 @@ fn parse_workdir_scope(rules: &[ScopeRuleInput]) -> Result<Vec<WorkdirDelegation
|
|||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workdir_delegation_request(
|
fn workdir_tool_scope(
|
||||||
cwd: Option<&str>,
|
cwd: Option<&str>,
|
||||||
rules: Vec<WorkdirDelegationRule>,
|
rules: Vec<WorkdirToolScopeRule>,
|
||||||
) -> Result<WorkdirDelegationRequest, ToolError> {
|
command: bool,
|
||||||
Ok(WorkdirDelegationRequest {
|
) -> Result<WorkdirToolScope, ToolError> {
|
||||||
|
Ok(WorkdirToolScope {
|
||||||
rules,
|
rules,
|
||||||
cwd: logical_workdir_path(cwd.unwrap_or("."), "cwd")?,
|
cwd: logical_workdir_path(cwd.unwrap_or("."), "cwd")?,
|
||||||
|
command,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn require_active_workdir_session(
|
fn require_workdir_tool_broker(
|
||||||
session: Option<&WorkdirSessionHandle>,
|
broker: Option<&WorkdirToolBroker>,
|
||||||
) -> Result<&WorkdirSessionHandle, ToolError> {
|
) -> Result<&WorkdirToolBroker, ToolError> {
|
||||||
session.ok_or_else(|| {
|
broker.ok_or_else(|| {
|
||||||
ToolError::InvalidArgument(
|
ToolError::InvalidArgument(
|
||||||
"SubWorkerSpawn requires an active Workdir session; attach a Workdir before delegating filesystem access"
|
"SubWorkerSpawn requires parent-owned Workdir tools; attach a Workdir before granting filesystem access"
|
||||||
.to_string(),
|
.to_string(),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -963,7 +967,7 @@ pub(crate) fn sub_worker_spawn_tool(
|
|||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
bash_output_dir: PathBuf,
|
bash_output_dir: PathBuf,
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
source_workdir_session: Option<WorkdirSessionHandle>,
|
workdir_tool_broker: Option<WorkdirToolBroker>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
spawner_manifest: WorkerManifest,
|
spawner_manifest: WorkerManifest,
|
||||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
@@ -975,7 +979,7 @@ pub(crate) fn sub_worker_spawn_tool(
|
|||||||
runtime_base,
|
runtime_base,
|
||||||
bash_output_dir,
|
bash_output_dir,
|
||||||
workspace_root,
|
workspace_root,
|
||||||
source_workdir_session,
|
workdir_tool_broker,
|
||||||
registry,
|
registry,
|
||||||
spawner_manifest,
|
spawner_manifest,
|
||||||
prompts,
|
prompts,
|
||||||
@@ -989,7 +993,7 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
runtime_base: PathBuf,
|
runtime_base: PathBuf,
|
||||||
bash_output_dir: PathBuf,
|
bash_output_dir: PathBuf,
|
||||||
workspace_root: PathBuf,
|
workspace_root: PathBuf,
|
||||||
source_workdir_session: Option<WorkdirSessionHandle>,
|
workdir_tool_broker: Option<WorkdirToolBroker>,
|
||||||
registry: Arc<SpawnedWorkerRegistry>,
|
registry: Arc<SpawnedWorkerRegistry>,
|
||||||
spawner_manifest: WorkerManifest,
|
spawner_manifest: WorkerManifest,
|
||||||
prompts: Arc<ArcSwap<PromptCatalog>>,
|
prompts: Arc<ArcSwap<PromptCatalog>>,
|
||||||
@@ -1021,7 +1025,7 @@ fn sub_worker_spawn_tool_impl(
|
|||||||
runtime_base.clone(),
|
runtime_base.clone(),
|
||||||
bash_output_dir.clone(),
|
bash_output_dir.clone(),
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
source_workdir_session.clone(),
|
workdir_tool_broker.clone(),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
spawner_manifest.clone(),
|
spawner_manifest.clone(),
|
||||||
prompts.load_full().source(),
|
prompts.load_full().source(),
|
||||||
@@ -1054,12 +1058,12 @@ mod tests {
|
|||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn missing_active_workdir_session_fails_deterministically() {
|
fn missing_parent_workdir_tool_broker_fails_deterministically() {
|
||||||
let error = require_active_workdir_session(None).unwrap_err();
|
let error = require_workdir_tool_broker(None).unwrap_err();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
error,
|
error,
|
||||||
ToolError::InvalidArgument(message)
|
ToolError::InvalidArgument(message)
|
||||||
if message.contains("requires an active Workdir session")
|
if message.contains("requires parent-owned Workdir tools")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,6 +1100,7 @@ mod tests {
|
|||||||
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
let valid: SubWorkerSpawnInput = serde_json::from_value(serde_json::json!({
|
||||||
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
"name":"reviewer","task":"review","profile":"builtin:reviewer",
|
||||||
"scope":[{"target":"work","permission":"write"}],
|
"scope":[{"target":"work","permission":"write"}],
|
||||||
|
"command":true,
|
||||||
"review":{"ticket_id":"T1"}
|
"review":{"ticket_id":"T1"}
|
||||||
}))
|
}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1219,7 +1224,7 @@ enabled = false
|
|||||||
let fail_requests = Arc::new(AtomicBool::new(false));
|
let fail_requests = Arc::new(AtomicBool::new(false));
|
||||||
let prompt_loader = PromptCatalogSource::builtins_only();
|
let prompt_loader = PromptCatalogSource::builtins_only();
|
||||||
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(8);
|
let (parent_method_tx, mut parent_method_rx) = mpsc::channel(8);
|
||||||
let source_workdir_session = workdir::delegation_capable_session(Arc::new(
|
let workdir_tool_broker = workdir::WorkdirToolBroker::new(Arc::new(
|
||||||
workdir::LocalWorkdirSession::materialized_bound(
|
workdir::LocalWorkdirSession::materialized_bound(
|
||||||
workdir::Workdir::new("test-workdir"),
|
workdir::Workdir::new("test-workdir"),
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
@@ -1238,7 +1243,7 @@ enabled = false
|
|||||||
runtime.path().to_path_buf(),
|
runtime.path().to_path_buf(),
|
||||||
bash_output_dir.clone(),
|
bash_output_dir.clone(),
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
Some(source_workdir_session),
|
Some(workdir_tool_broker),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
manifest.clone(),
|
manifest.clone(),
|
||||||
prompt_loader,
|
prompt_loader,
|
||||||
@@ -1261,7 +1266,8 @@ enabled = false
|
|||||||
"target": ".",
|
"target": ".",
|
||||||
"permission": "write",
|
"permission": "write",
|
||||||
"recursive": true
|
"recursive": true
|
||||||
}]
|
}],
|
||||||
|
"command": true
|
||||||
});
|
});
|
||||||
|
|
||||||
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
assert!(spawner_scope.snapshot().is_writable(&workspace_root));
|
||||||
@@ -1296,15 +1302,6 @@ enabled = false
|
|||||||
let record = registry
|
let record = registry
|
||||||
.get_internal("reviewer-child")
|
.get_internal("reviewer-child")
|
||||||
.expect("Internal reviewer registry record");
|
.expect("Internal reviewer registry record");
|
||||||
let child_bash_output_dir = bash_output_dir.join("sub-workers").join("reviewer-child");
|
|
||||||
record
|
|
||||||
.workdir_delegation
|
|
||||||
.scoped_session
|
|
||||||
.stat(workdir::StatRequest {
|
|
||||||
path: WorkdirPath::new_scoped(child_bash_output_dir.to_string_lossy()).unwrap(),
|
|
||||||
})
|
|
||||||
.await
|
|
||||||
.expect("local child retains read scope for its Bash output directory");
|
|
||||||
for required in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
|
for required in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
|
||||||
assert!(
|
assert!(
|
||||||
record.installed_tools.iter().any(|name| name == required),
|
record.installed_tools.iter().any(|name| name == required),
|
||||||
@@ -1423,7 +1420,7 @@ enabled = false
|
|||||||
"Stopped terminal child must release its delegated Workdir session"
|
"Stopped terminal child must release its delegated Workdir session"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!record.workdir_delegation.is_active(),
|
!record.workdir_tool_scope.is_active(),
|
||||||
"stopped child must revoke cloned scoped sessions"
|
"stopped child must revoke cloned scoped sessions"
|
||||||
);
|
);
|
||||||
assert!(registry.get_internal("reviewer-child").is_some());
|
assert!(registry.get_internal("reviewer-child").is_some());
|
||||||
@@ -1478,7 +1475,7 @@ enabled = false
|
|||||||
Arc::new(AvailableWorkspaceClient),
|
Arc::new(AvailableWorkspaceClient),
|
||||||
);
|
);
|
||||||
let remote_client = Arc::new(StrictRemoteWorkdirWorkspaceClient::default());
|
let remote_client = Arc::new(StrictRemoteWorkdirWorkspaceClient::default());
|
||||||
let source_workdir_session = workdir::delegation_capable_session(
|
let workdir_tool_broker = workdir::WorkdirToolBroker::new(
|
||||||
WorkspaceAttachedWorkdirSession::handle(remote_client.clone()),
|
WorkspaceAttachedWorkdirSession::handle(remote_client.clone()),
|
||||||
);
|
);
|
||||||
let calls = Arc::new(AtomicUsize::new(0));
|
let calls = Arc::new(AtomicUsize::new(0));
|
||||||
@@ -1493,7 +1490,7 @@ enabled = false
|
|||||||
runtime.path().to_path_buf(),
|
runtime.path().to_path_buf(),
|
||||||
bash_output_dir.clone(),
|
bash_output_dir.clone(),
|
||||||
workspace_root.clone(),
|
workspace_root.clone(),
|
||||||
Some(source_workdir_session),
|
Some(workdir_tool_broker),
|
||||||
registry.clone(),
|
registry.clone(),
|
||||||
manifest,
|
manifest,
|
||||||
PromptCatalogSource::builtins_only(),
|
PromptCatalogSource::builtins_only(),
|
||||||
@@ -1533,51 +1530,12 @@ enabled = false
|
|||||||
record.session.wait_until_idle().await,
|
record.session.wait_until_idle().await,
|
||||||
crate::internal_worker::InternalWorkerSessionStatus::Idle
|
crate::internal_worker::InternalWorkerSessionStatus::Idle
|
||||||
);
|
);
|
||||||
|
assert!(record.installed_tools.iter().any(|tool| tool == "Write"));
|
||||||
|
assert!(!record.installed_tools.iter().any(|tool| tool == "Bash"));
|
||||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||||
assert_eq!(
|
|
||||||
remote_client
|
|
||||||
.foreign_scope_rejections
|
|
||||||
.load(Ordering::SeqCst),
|
|
||||||
0
|
|
||||||
);
|
|
||||||
let child_bash_output_dir = bash_output_dir.join("sub-workers").join("remote-child");
|
|
||||||
assert!(child_bash_output_dir.is_dir());
|
|
||||||
for required in ["Read", "Write", "Edit", "Glob", "Grep", "Bash"] {
|
|
||||||
assert!(
|
|
||||||
record.installed_tools.iter().any(|name| name == required),
|
|
||||||
"remote write-scoped child is missing {required}: {:?}",
|
|
||||||
record.installed_tools
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let remote_requests = remote_client.requests();
|
|
||||||
let operate_requests = remote_requests
|
|
||||||
.iter()
|
|
||||||
.filter(|request| request.body.is_some())
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
assert_eq!(
|
|
||||||
operate_requests.len(),
|
|
||||||
1,
|
|
||||||
"remote requests: {remote_requests:?}"
|
|
||||||
);
|
|
||||||
let operation_body: serde_json::Value = serde_json::from_str(
|
|
||||||
operate_requests[0]
|
|
||||||
.body
|
|
||||||
.as_deref()
|
|
||||||
.expect("remote operation body"),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
let rules = operation_body["delegations"][0]["rules"]
|
|
||||||
.as_array()
|
|
||||||
.expect("delegation rules");
|
|
||||||
assert_eq!(rules.len(), 1, "remote operation body: {operation_body}");
|
|
||||||
assert_eq!(rules[0]["target"], "");
|
|
||||||
assert!(
|
assert!(
|
||||||
!operation_body.to_string().contains(
|
remote_client.requests().is_empty(),
|
||||||
child_bash_output_dir
|
"spawning a child must not open or delegate a provider Workdir session"
|
||||||
.to_str()
|
|
||||||
.expect("UTF-8 test output directory")
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1589,6 +1547,7 @@ enabled = false
|
|||||||
.and_then(serde_json::Value::as_object)
|
.and_then(serde_json::Value::as_object)
|
||||||
.expect("schema properties");
|
.expect("schema properties");
|
||||||
assert!(properties.contains_key("cwd"), "schema: {schema}");
|
assert!(properties.contains_key("cwd"), "schema: {schema}");
|
||||||
|
assert!(properties.contains_key("command"), "schema: {schema}");
|
||||||
let required = schema
|
let required = schema
|
||||||
.get("required")
|
.get("required")
|
||||||
.and_then(serde_json::Value::as_array)
|
.and_then(serde_json::Value::as_array)
|
||||||
@@ -1718,7 +1677,6 @@ enabled = false
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
struct StrictRemoteWorkdirWorkspaceClient {
|
struct StrictRemoteWorkdirWorkspaceClient {
|
||||||
requests: Mutex<Vec<WorkspaceRequest>>,
|
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||||
foreign_scope_rejections: AtomicUsize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl StrictRemoteWorkdirWorkspaceClient {
|
impl StrictRemoteWorkdirWorkspaceClient {
|
||||||
@@ -1750,59 +1708,10 @@ enabled = false
|
|||||||
self.requests
|
self.requests
|
||||||
.lock()
|
.lock()
|
||||||
.expect("remote Workdir request lock")
|
.expect("remote Workdir request lock")
|
||||||
.push(request.clone());
|
.push(request);
|
||||||
if request.path.ends_with("/fence") {
|
Err(WorkspaceClientError::Request(
|
||||||
return Ok(WorkspaceResponse {
|
"SubWorker spawn must not call the remote Workdir provider".into(),
|
||||||
status: 200,
|
))
|
||||||
body: serde_json::json!({ "value": "remote-fence-1" }).to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: serde_json::Value = serde_json::from_str(
|
|
||||||
request
|
|
||||||
.body
|
|
||||||
.as_deref()
|
|
||||||
.ok_or_else(|| WorkspaceClientError::Request("missing request body".into()))?,
|
|
||||||
)
|
|
||||||
.map_err(|error| WorkspaceClientError::Request(error.to_string()))?;
|
|
||||||
let has_foreign_scope = body
|
|
||||||
.get("delegations")
|
|
||||||
.and_then(serde_json::Value::as_array)
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
.flat_map(|delegation| {
|
|
||||||
delegation
|
|
||||||
.get("rules")
|
|
||||||
.and_then(serde_json::Value::as_array)
|
|
||||||
.into_iter()
|
|
||||||
.flatten()
|
|
||||||
})
|
|
||||||
.filter_map(|rule| rule.get("target").and_then(serde_json::Value::as_str))
|
|
||||||
.any(|target| Path::new(target).is_absolute());
|
|
||||||
if has_foreign_scope {
|
|
||||||
self.foreign_scope_rejections.fetch_add(1, Ordering::SeqCst);
|
|
||||||
return Ok(WorkspaceResponse {
|
|
||||||
status: 403,
|
|
||||||
body: serde_json::json!({
|
|
||||||
"code": "out_of_scope",
|
|
||||||
"message": "Worker-host path is outside the remote Workdir namespace"
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(WorkspaceResponse {
|
|
||||||
status: 200,
|
|
||||||
body: serde_json::json!({
|
|
||||||
"operation": "stat",
|
|
||||||
"result": {
|
|
||||||
"path": "",
|
|
||||||
"kind": "directory",
|
|
||||||
"size": 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.to_string(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -346,6 +346,7 @@ async fn shutdown_closes_bound_workdir_session() {
|
|||||||
command: "sleep 30".to_owned(),
|
command: "sleep 30".to_owned(),
|
||||||
timeout_secs: 60,
|
timeout_secs: 60,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
spill_dir: None,
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
@@ -395,6 +396,7 @@ async fn controller_projects_workdir_command_events_and_snapshot_state() {
|
|||||||
command: "printf ready; sleep 0.3; printf done".to_owned(),
|
command: "printf ready; sleep 0.3; printf done".to_owned(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
spill_dir: None,
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-command-1".into()),
|
tool_call_id: Some("tool-command-1".into()),
|
||||||
})
|
})
|
||||||
@@ -508,6 +510,7 @@ async fn controller_refreshes_command_snapshot_after_high_output_provider_lag()
|
|||||||
.to_owned(),
|
.to_owned(),
|
||||||
timeout_secs: 10,
|
timeout_secs: 10,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
spill_dir: None,
|
spill_dir: None,
|
||||||
tool_call_id: Some("tool-high-output".into()),
|
tool_call_id: Some("tool-high-output".into()),
|
||||||
})
|
})
|
||||||
@@ -589,6 +592,7 @@ async fn controller_startup_failure_closes_bound_workdir_session() {
|
|||||||
command: "printf unreachable".to_owned(),
|
command: "printf unreachable".to_owned(),
|
||||||
timeout_secs: 5,
|
timeout_secs: 5,
|
||||||
output_limit: 1024,
|
output_limit: 1024,
|
||||||
|
cwd: None,
|
||||||
spill_dir: None,
|
spill_dir: None,
|
||||||
tool_call_id: None,
|
tool_call_id: None,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -539,6 +539,7 @@ pub enum WorkspaceAuthConfig {
|
|||||||
pub struct WorkspacePermissionSummary {
|
pub struct WorkspacePermissionSummary {
|
||||||
pub manage_repositories: bool,
|
pub manage_repositories: bool,
|
||||||
pub manage_secrets: bool,
|
pub manage_secrets: bool,
|
||||||
|
pub manage_runtimes: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
@@ -1135,6 +1136,7 @@ pub struct ObjectiveLinkTicketRequest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RuntimeSourceKind {
|
pub enum RuntimeSourceKind {
|
||||||
EmbeddedWorkerRuntime,
|
EmbeddedWorkerRuntime,
|
||||||
@@ -1142,6 +1144,7 @@ pub enum RuntimeSourceKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RuntimeSourceStatus {
|
pub enum RuntimeSourceStatus {
|
||||||
Active,
|
Active,
|
||||||
@@ -1149,6 +1152,7 @@ pub enum RuntimeSourceStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RuntimeIdentityAuthority {
|
pub enum RuntimeIdentityAuthority {
|
||||||
RuntimeRegistryProjection,
|
RuntimeRegistryProjection,
|
||||||
@@ -1156,6 +1160,8 @@ pub enum RuntimeIdentityAuthority {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RuntimeSourceSummary {
|
pub struct RuntimeSourceSummary {
|
||||||
pub kind: RuntimeSourceKind,
|
pub kind: RuntimeSourceKind,
|
||||||
pub status: RuntimeSourceStatus,
|
pub status: RuntimeSourceStatus,
|
||||||
@@ -1164,6 +1170,7 @@ pub struct RuntimeSourceSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct RuntimeSummary {
|
pub struct RuntimeSummary {
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub label: String,
|
pub label: String,
|
||||||
@@ -1180,6 +1187,8 @@ pub struct RuntimeSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct RuntimeManagementSummary {
|
pub struct RuntimeManagementSummary {
|
||||||
pub built_in: bool,
|
pub built_in: bool,
|
||||||
pub config_managed: bool,
|
pub config_managed: bool,
|
||||||
@@ -1189,12 +1198,124 @@ pub struct RuntimeManagementSummary {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
pub struct WorkspaceRuntimeResource {
|
pub struct WorkspaceRuntimeResource {
|
||||||
#[serde(flatten)]
|
#[serde(flatten)]
|
||||||
pub runtime: RuntimeSummary,
|
pub runtime: RuntimeSummary,
|
||||||
pub management: RuntimeManagementSummary,
|
pub management: RuntimeManagementSummary,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeTrustKeyStatus {
|
||||||
|
Unconfigured,
|
||||||
|
Active,
|
||||||
|
Revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimeTrustKeyState {
|
||||||
|
pub status: RuntimeTrustKeyStatus,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub fingerprint: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
pub revision: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub updated_at: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeTrustAuditAction {
|
||||||
|
Created,
|
||||||
|
Replaced,
|
||||||
|
Reactivated,
|
||||||
|
Revoked,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimeTrustAuditEntry {
|
||||||
|
pub action: RuntimeTrustAuditAction,
|
||||||
|
pub actor_account_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub old_fingerprint: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub new_fingerprint: Option<String>,
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub revision: u64,
|
||||||
|
pub at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct WorkspaceRuntimeDetail {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub runtime: WorkspaceRuntimeResource,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub endpoint: Option<String>,
|
||||||
|
pub trust_key: RuntimeTrustKeyState,
|
||||||
|
#[serde(default)]
|
||||||
|
pub recent_audit: Vec<RuntimeTrustAuditEntry>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimeTrustKeyRevealResponse {
|
||||||
|
pub public_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct PutRuntimeTrustKeyRequest {
|
||||||
|
pub public_key: String,
|
||||||
|
#[serde(default)]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||||
|
pub expected_revision: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RevokeRuntimeTrustKeyRequest {
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub expected_revision: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum RuntimeTrustConflictKind {
|
||||||
|
StaleRevision,
|
||||||
|
FingerprintInUse,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct RuntimeTrustConflictResponse {
|
||||||
|
pub error: RuntimeTrustConflictKind,
|
||||||
|
pub message: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||||
|
pub current_revision: Option<u64>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub current_fingerprint: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CreateRemoteRuntimeRequest {
|
pub struct CreateRemoteRuntimeRequest {
|
||||||
@@ -2394,6 +2515,23 @@ pub fn catalog_typescript() -> String {
|
|||||||
RepositoryListResponse::decl(&config),
|
RepositoryListResponse::decl(&config),
|
||||||
RepositoryDetailResponse::decl(&config),
|
RepositoryDetailResponse::decl(&config),
|
||||||
RepositoryLogResponse::decl(&config),
|
RepositoryLogResponse::decl(&config),
|
||||||
|
RuntimeSourceKind::decl(&config),
|
||||||
|
RuntimeSourceStatus::decl(&config),
|
||||||
|
RuntimeIdentityAuthority::decl(&config),
|
||||||
|
RuntimeSourceSummary::decl(&config),
|
||||||
|
RuntimeSummary::decl(&config),
|
||||||
|
RuntimeManagementSummary::decl(&config),
|
||||||
|
WorkspaceRuntimeResource::decl(&config),
|
||||||
|
RuntimeTrustKeyStatus::decl(&config),
|
||||||
|
RuntimeTrustKeyState::decl(&config),
|
||||||
|
RuntimeTrustAuditAction::decl(&config),
|
||||||
|
RuntimeTrustAuditEntry::decl(&config),
|
||||||
|
WorkspaceRuntimeDetail::decl(&config),
|
||||||
|
RuntimeTrustKeyRevealResponse::decl(&config),
|
||||||
|
PutRuntimeTrustKeyRequest::decl(&config),
|
||||||
|
RevokeRuntimeTrustKeyRequest::decl(&config),
|
||||||
|
RuntimeTrustConflictKind::decl(&config),
|
||||||
|
RuntimeTrustConflictResponse::decl(&config),
|
||||||
RuntimeConnectionTestStatus::decl(&config),
|
RuntimeConnectionTestStatus::decl(&config),
|
||||||
RuntimeConnectionTestFailureKind::decl(&config),
|
RuntimeConnectionTestFailureKind::decl(&config),
|
||||||
RuntimeConnectionTestResponse::decl(&config),
|
RuntimeConnectionTestResponse::decl(&config),
|
||||||
@@ -3022,7 +3160,8 @@ mod tests {
|
|||||||
}},
|
}},
|
||||||
"permissions": {
|
"permissions": {
|
||||||
"manage_repositories": true,
|
"manage_repositories": true,
|
||||||
"manage_secrets": true
|
"manage_secrets": true,
|
||||||
|
"manage_runtimes": true
|
||||||
},
|
},
|
||||||
"extension_points": {
|
"extension_points": {
|
||||||
"store": "sqlite",
|
"store": "sqlite",
|
||||||
@@ -3086,6 +3225,81 @@ mod tests {
|
|||||||
assert!(serde_json::from_value::<RepositoryListResponse>(stale).is_err());
|
assert!(serde_json::from_value::<RepositoryListResponse>(stale).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn runtime_detail_and_trust_mutations_are_closed_and_typed() {
|
||||||
|
let detail = serde_json::json!({
|
||||||
|
"workspace_id": "workspace-test",
|
||||||
|
"runtime": {
|
||||||
|
"runtime_id": "runtime-test",
|
||||||
|
"label": "Runtime Test",
|
||||||
|
"kind": "remote_http",
|
||||||
|
"status": "active",
|
||||||
|
"source": {
|
||||||
|
"kind": "remote_http",
|
||||||
|
"status": "active",
|
||||||
|
"identity_authority": "runtime_registry_projection",
|
||||||
|
"note": "active"
|
||||||
|
},
|
||||||
|
"host_ids": [],
|
||||||
|
"worker_creation_available": true,
|
||||||
|
"os": "linux",
|
||||||
|
"arch": "x86_64",
|
||||||
|
"diagnostics": [],
|
||||||
|
"management": {
|
||||||
|
"built_in": false,
|
||||||
|
"config_managed": true,
|
||||||
|
"removable": true,
|
||||||
|
"endpoint_configured": true,
|
||||||
|
"token_ref_configured": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"endpoint": "https://runtime.example",
|
||||||
|
"trust_key": {
|
||||||
|
"status": "active",
|
||||||
|
"fingerprint": "SHA256:test",
|
||||||
|
"revision": 2,
|
||||||
|
"created_at": "2026-09-01T12:00:00Z",
|
||||||
|
"updated_at": "2026-09-01T13:00:00Z"
|
||||||
|
},
|
||||||
|
"recent_audit": [{
|
||||||
|
"action": "replaced",
|
||||||
|
"actor_account_id": "account-owner",
|
||||||
|
"old_fingerprint": "SHA256:old",
|
||||||
|
"new_fingerprint": "SHA256:test",
|
||||||
|
"revision": 2,
|
||||||
|
"at": "2026-09-01T13:00:00Z"
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
let parsed: WorkspaceRuntimeDetail = serde_json::from_value(detail.clone()).unwrap();
|
||||||
|
assert_eq!(serde_json::to_value(parsed).unwrap(), detail);
|
||||||
|
|
||||||
|
let mut unknown = detail;
|
||||||
|
unknown["trust_key"]["private_key"] = serde_json::json!("forbidden");
|
||||||
|
assert!(serde_json::from_value::<WorkspaceRuntimeDetail>(unknown).is_err());
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<RuntimeTrustKeyRevealResponse>(serde_json::json!({
|
||||||
|
"public_key": "yoi-ed25519-pub:v1:key",
|
||||||
|
"private_key": "forbidden"
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
|
||||||
|
"public_key": "key",
|
||||||
|
"expected_revision": 1,
|
||||||
|
"replace": true
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
|
||||||
|
"expected_revision": 1,
|
||||||
|
"delete_runtime": true
|
||||||
|
}))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_connection_test_response_is_closed_and_typed() {
|
fn runtime_connection_test_response_is_closed_and_typed() {
|
||||||
let compatible = serde_json::json!({
|
let compatible = serde_json::json!({
|
||||||
|
|||||||
@@ -440,6 +440,7 @@ CREATE TABLE workspace_runtime_bindings (
|
|||||||
base_url TEXT NOT NULL,
|
base_url TEXT NOT NULL,
|
||||||
public_key TEXT NOT NULL,
|
public_key TEXT NOT NULL,
|
||||||
public_key_fingerprint TEXT NOT NULL,
|
public_key_fingerprint TEXT NOT NULL,
|
||||||
|
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
|
||||||
created_at TEXT NOT NULL,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
revoked_at TEXT,
|
revoked_at TEXT,
|
||||||
@@ -447,6 +448,22 @@ CREATE TABLE workspace_runtime_bindings (
|
|||||||
UNIQUE (workspace_id, public_key_fingerprint),
|
UNIQUE (workspace_id, public_key_fingerprint),
|
||||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
||||||
);
|
);
|
||||||
|
CREATE TABLE workspace_runtime_binding_audit (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
runtime_id TEXT NOT NULL,
|
||||||
|
actor_account_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')),
|
||||||
|
old_fingerprint TEXT,
|
||||||
|
new_fingerprint TEXT,
|
||||||
|
binding_revision INTEGER NOT NULL CHECK (binding_revision > 0),
|
||||||
|
at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (workspace_id, runtime_id, binding_revision),
|
||||||
|
FOREIGN KEY(workspace_id, runtime_id)
|
||||||
|
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT,
|
||||||
|
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_workspace_runtime_binding_audit_recent
|
||||||
|
ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC);
|
||||||
CREATE TABLE typed_ticket_artifacts (
|
CREATE TABLE typed_ticket_artifacts (
|
||||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
||||||
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
||||||
|
|||||||
@@ -120,6 +120,15 @@ pub enum Error {
|
|||||||
WorkspaceConfigConflict(String),
|
WorkspaceConfigConflict(String),
|
||||||
#[error("Runtime binding conflict: {0}")]
|
#[error("Runtime binding conflict: {0}")]
|
||||||
RuntimeBindingConflict(String),
|
RuntimeBindingConflict(String),
|
||||||
|
#[error("Runtime binding revision conflict: expected {expected:?}, current {actual:?}")]
|
||||||
|
RuntimeBindingRevisionConflict {
|
||||||
|
expected: Option<u64>,
|
||||||
|
actual: Option<u64>,
|
||||||
|
},
|
||||||
|
#[error("Runtime public key fingerprint is already bound in this Workspace: {fingerprint}")]
|
||||||
|
RuntimeBindingFingerprintConflict { fingerprint: String },
|
||||||
|
#[error("Runtime binding was not found for {runtime_id}")]
|
||||||
|
RuntimeBindingNotFound { runtime_id: String },
|
||||||
#[error("Repository conflict: {0}")]
|
#[error("Repository conflict: {0}")]
|
||||||
RepositoryConflict(String),
|
RepositoryConflict(String),
|
||||||
#[error("Registry inconsistency: {0}")]
|
#[error("Registry inconsistency: {0}")]
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
|||||||
base_url,
|
base_url,
|
||||||
public_key,
|
public_key,
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
created_at: now.clone(),
|
created_at: now.clone(),
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
@@ -859,6 +860,7 @@ mod tests {
|
|||||||
base_url: "http://127.0.0.1:18080".to_string(),
|
base_url: "http://127.0.0.1:18080".to_string(),
|
||||||
public_key,
|
public_key,
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
|
binding_revision: 1,
|
||||||
created_at: "2026-07-26T00:00:00Z".to_string(),
|
created_at: "2026-07-26T00:00:00Z".to_string(),
|
||||||
updated_at: "2026-07-26T00:00:00Z".to_string(),
|
updated_at: "2026-07-26T00:00:00Z".to_string(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -15,7 +15,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
review = {
|
review = {
|
||||||
instructions = "Use the current Ticket Merge Request as review authority. Call `ShowMergeRequest` and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope for Workdir inspection and command validation, and only the Ticket id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition.";
|
instructions = "Use the current Ticket Merge Request as review authority. Call `ShowMergeRequest` and confirm its source selector resolves to exact committed implementation HEAD, then spawn one actual direct-child SubWorker with profile builtin:reviewer, write scope plus an explicit command grant for Workdir inspection and command validation, and only the Ticket id in the structured review handoff. The trusted spawn layer records `ReviewRequested` with the exact source ref and injects review capability; do not place commit/ref identity, capability material, or a prewritten verdict in model input. The child must commit `ReviewMergeRequest`; prose output and Worker observation are not approval authority. After the structured result for the exact current source ref exists, request a Flow transition.";
|
||||||
transitions = {
|
transitions = {
|
||||||
approved = {
|
approved = {
|
||||||
target = "complete";
|
target = "complete";
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits.
|
Spawn a parent-owned Internal SubWorker session to split context for a delegated task. The parent Worker's write scope is reduced by the scope passed here; the Internal SubWorker starts running `task` immediately without creating a Runtime Worker record, OS process, PID, or Unix socket. It remains available for follow-up turns until explicitly stopped or its parent exits.
|
||||||
|
|
||||||
Optional `cwd`: when provided, the spawned SubWorker's tool default working directory only. It must be an absolute existing directory covered by the child's delegated readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children.
|
Optional `cwd`: when provided, the spawned SubWorker's tool default working directory only. It must be a Workdir-relative existing directory covered by the child's readable scope, and it does not change workspace/Profile/memory/Ticket roots or grant authority. `name` must be unique among this Worker's direct children.
|
||||||
|
|
||||||
Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is always the only delegated filesystem capability; profile scope is replaced by the explicit SubWorkerSpawn scope.
|
Profile selection: `profile` may be omitted or set to `default` to use the effective child default profile, set to `inherit` to derive reusable child configuration from this Worker, or set to one of the registry selectors below. Raw/path profile selectors are not accepted by SubWorkerSpawn. `scope` is the child's only filesystem capability and replaces profile scope. `command` is a separate explicit grant, defaults to false, and is accepted only with a writable scope; writable scope alone does not grant command execution.
|
||||||
|
|
||||||
Default profile: {{ default_profile }}
|
Default profile: {{ default_profile }}
|
||||||
Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope.
|
Special selector: inherit — derive reusable model/worker/tool policy from the spawner while replacing worker.name and scope.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"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",
|
"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",
|
"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 --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.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 src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.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/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 src/lib/workspace/sidebar/override-stack.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-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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.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 src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.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/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/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.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/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||||
"build": "deno run -A npm:vite@7.2.7 build",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export type WorkspaceAuthConfig = {
|
|||||||
export type WorkspacePermissionSummary = {
|
export type WorkspacePermissionSummary = {
|
||||||
manage_repositories: boolean;
|
manage_repositories: boolean;
|
||||||
manage_secrets: boolean;
|
manage_secrets: boolean;
|
||||||
|
manage_runtimes: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||||
@@ -221,6 +222,108 @@ export type RepositoryLogResponse = {
|
|||||||
diagnostics: Array<Diagnostic>;
|
diagnostics: Array<Diagnostic>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type RuntimeSourceKind = "embedded_worker_runtime" | "remote_http";
|
||||||
|
|
||||||
|
export type RuntimeSourceStatus = "active" | "reserved";
|
||||||
|
|
||||||
|
export type RuntimeIdentityAuthority =
|
||||||
|
| "runtime_registry_projection"
|
||||||
|
| "server_runtime_configuration";
|
||||||
|
|
||||||
|
export type RuntimeSourceSummary = {
|
||||||
|
kind: RuntimeSourceKind;
|
||||||
|
status: RuntimeSourceStatus;
|
||||||
|
identity_authority: RuntimeIdentityAuthority;
|
||||||
|
note: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeSummary = {
|
||||||
|
runtime_id: string;
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
source: RuntimeSourceSummary;
|
||||||
|
host_ids: Array<string>;
|
||||||
|
worker_creation_available: boolean;
|
||||||
|
os: string;
|
||||||
|
arch: string;
|
||||||
|
diagnostics: Array<Diagnostic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeManagementSummary = {
|
||||||
|
built_in: boolean;
|
||||||
|
config_managed: boolean;
|
||||||
|
removable: boolean;
|
||||||
|
endpoint_configured: boolean;
|
||||||
|
token_ref_configured: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceRuntimeResource = {
|
||||||
|
management: RuntimeManagementSummary;
|
||||||
|
runtime_id: string;
|
||||||
|
label: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
source: RuntimeSourceSummary;
|
||||||
|
host_ids: Array<string>;
|
||||||
|
worker_creation_available: boolean;
|
||||||
|
os: string;
|
||||||
|
arch: string;
|
||||||
|
diagnostics: Array<Diagnostic>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
|
||||||
|
|
||||||
|
export type RuntimeTrustKeyState = {
|
||||||
|
status: RuntimeTrustKeyStatus;
|
||||||
|
fingerprint?: string | null;
|
||||||
|
revision?: number | null;
|
||||||
|
created_at?: string | null;
|
||||||
|
updated_at?: string | null;
|
||||||
|
revoked_at?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeTrustAuditAction =
|
||||||
|
| "created"
|
||||||
|
| "replaced"
|
||||||
|
| "reactivated"
|
||||||
|
| "revoked";
|
||||||
|
|
||||||
|
export type RuntimeTrustAuditEntry = {
|
||||||
|
action: RuntimeTrustAuditAction;
|
||||||
|
actor_account_id: string;
|
||||||
|
old_fingerprint?: string | null;
|
||||||
|
new_fingerprint?: string | null;
|
||||||
|
revision: number;
|
||||||
|
at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type WorkspaceRuntimeDetail = {
|
||||||
|
workspace_id: string;
|
||||||
|
runtime: WorkspaceRuntimeResource;
|
||||||
|
endpoint?: string | null;
|
||||||
|
trust_key: RuntimeTrustKeyState;
|
||||||
|
recent_audit: Array<RuntimeTrustAuditEntry>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeTrustKeyRevealResponse = { public_key: string };
|
||||||
|
|
||||||
|
export type PutRuntimeTrustKeyRequest = {
|
||||||
|
public_key: string;
|
||||||
|
expected_revision: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
|
||||||
|
|
||||||
|
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
|
||||||
|
|
||||||
|
export type RuntimeTrustConflictResponse = {
|
||||||
|
error: RuntimeTrustConflictKind;
|
||||||
|
message: string;
|
||||||
|
current_revision?: number;
|
||||||
|
current_fingerprint?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
||||||
|
|
||||||
export type RuntimeConnectionTestFailureKind =
|
export type RuntimeConnectionTestFailureKind =
|
||||||
|
|||||||
@@ -0,0 +1,816 @@
|
|||||||
|
import type {
|
||||||
|
Diagnostic,
|
||||||
|
PutRuntimeTrustKeyRequest,
|
||||||
|
RevokeRuntimeTrustKeyRequest,
|
||||||
|
RuntimeIdentityAuthority,
|
||||||
|
RuntimeManagementSummary,
|
||||||
|
RuntimeSourceKind,
|
||||||
|
RuntimeSourceStatus,
|
||||||
|
RuntimeSourceSummary,
|
||||||
|
RuntimeTrustAuditAction,
|
||||||
|
RuntimeTrustAuditEntry,
|
||||||
|
RuntimeTrustConflictKind,
|
||||||
|
RuntimeTrustConflictResponse,
|
||||||
|
RuntimeTrustKeyRevealResponse,
|
||||||
|
RuntimeTrustKeyState,
|
||||||
|
RuntimeTrustKeyStatus,
|
||||||
|
WorkspaceRuntimeDetail,
|
||||||
|
WorkspaceRuntimeResource,
|
||||||
|
} from "$lib/generated/workspace-api.ts";
|
||||||
|
import type { ListResponse } from "$lib/workspace/sidebar/types";
|
||||||
|
import { workspaceApiPath } from "./http.ts";
|
||||||
|
|
||||||
|
export type WorkspaceRuntimeList = ListResponse<WorkspaceRuntimeResource>;
|
||||||
|
|
||||||
|
const LIMITS = {
|
||||||
|
runtimeItems: 200,
|
||||||
|
auditEntries: 20,
|
||||||
|
hostIds: 128,
|
||||||
|
diagnostics: 64,
|
||||||
|
idBytes: 256,
|
||||||
|
labelBytes: 512,
|
||||||
|
kindBytes: 128,
|
||||||
|
statusBytes: 128,
|
||||||
|
noteBytes: 2_048,
|
||||||
|
endpointBytes: 4_096,
|
||||||
|
publicKeyBytes: 16 * 1_024,
|
||||||
|
fingerprintBytes: 512,
|
||||||
|
timestampBytes: 128,
|
||||||
|
diagnosticCodeBytes: 128,
|
||||||
|
diagnosticMessageBytes: 2_048,
|
||||||
|
conflictMessageBytes: 1_024,
|
||||||
|
responseBytes: 512 * 1_024,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const SOURCE_KINDS = new Set<RuntimeSourceKind>([
|
||||||
|
"embedded_worker_runtime",
|
||||||
|
"remote_http",
|
||||||
|
]);
|
||||||
|
const SOURCE_STATUSES = new Set<RuntimeSourceStatus>(["active", "reserved"]);
|
||||||
|
const IDENTITY_AUTHORITIES = new Set<RuntimeIdentityAuthority>([
|
||||||
|
"runtime_registry_projection",
|
||||||
|
"server_runtime_configuration",
|
||||||
|
]);
|
||||||
|
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]);
|
||||||
|
const TRUST_STATUSES = new Set<RuntimeTrustKeyStatus>([
|
||||||
|
"unconfigured",
|
||||||
|
"active",
|
||||||
|
"revoked",
|
||||||
|
]);
|
||||||
|
const AUDIT_ACTIONS = new Set<RuntimeTrustAuditAction>([
|
||||||
|
"created",
|
||||||
|
"replaced",
|
||||||
|
"reactivated",
|
||||||
|
"revoked",
|
||||||
|
]);
|
||||||
|
const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
|
||||||
|
"stale_revision",
|
||||||
|
"fingerprint_in_use",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
type JsonObject = Record<string, unknown>;
|
||||||
|
|
||||||
|
export class RuntimeManagementValidationError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message.slice(0, 256));
|
||||||
|
this.name = "RuntimeManagementValidationError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RuntimeTrustConflictError extends Error {
|
||||||
|
readonly conflict: RuntimeTrustConflictResponse;
|
||||||
|
|
||||||
|
constructor(conflict: RuntimeTrustConflictResponse) {
|
||||||
|
super(conflict.message);
|
||||||
|
this.name = "RuntimeTrustConflictError";
|
||||||
|
this.conflict = conflict;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RuntimeTrustRequestError extends Error {
|
||||||
|
readonly field: "public_key" | null;
|
||||||
|
|
||||||
|
constructor(message: string, field: "public_key" | null = null) {
|
||||||
|
super(message.slice(0, 256));
|
||||||
|
this.name = "RuntimeTrustRequestError";
|
||||||
|
this.field = field;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeTrustRouteOperation = Readonly<{
|
||||||
|
runtimeId: string;
|
||||||
|
generation: number;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export class RuntimeTrustRouteFence {
|
||||||
|
#runtimeId: string | null = null;
|
||||||
|
#generation = 0;
|
||||||
|
|
||||||
|
enter(runtimeId: string): number {
|
||||||
|
if (this.#runtimeId !== runtimeId) {
|
||||||
|
this.#runtimeId = runtimeId;
|
||||||
|
this.#generation += 1;
|
||||||
|
}
|
||||||
|
return this.#generation;
|
||||||
|
}
|
||||||
|
|
||||||
|
capture(runtimeId: string): RuntimeTrustRouteOperation {
|
||||||
|
return { runtimeId, generation: this.enter(runtimeId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
isCurrent(operation: RuntimeTrustRouteOperation, runtimeId: string): boolean {
|
||||||
|
return operation.runtimeId === runtimeId &&
|
||||||
|
operation.generation === this.#generation &&
|
||||||
|
this.#runtimeId === runtimeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(path: string, message: string): never {
|
||||||
|
throw new RuntimeManagementValidationError(`${path} ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function object(value: unknown, path: string): JsonObject {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
return fail(path, "must be an object");
|
||||||
|
}
|
||||||
|
return value as JsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exactKeys(
|
||||||
|
value: JsonObject,
|
||||||
|
required: readonly string[],
|
||||||
|
optional: readonly string[],
|
||||||
|
path: string,
|
||||||
|
): void {
|
||||||
|
const allowed = new Set([...required, ...optional]);
|
||||||
|
for (const key of Object.keys(value)) {
|
||||||
|
if (!allowed.has(key)) {
|
||||||
|
fail(`${path}.${key}`, "is not part of the wire contract");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const key of required) {
|
||||||
|
if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, "is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function array(value: unknown, path: string, max: number): unknown[] {
|
||||||
|
if (!Array.isArray(value)) return fail(path, "must be an array");
|
||||||
|
if (value.length > max) {
|
||||||
|
return fail(path, `must contain at most ${max} items`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedString(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
maxBytes: number,
|
||||||
|
allowEmpty = false,
|
||||||
|
): string {
|
||||||
|
if (typeof value !== "string") return fail(path, "must be a string");
|
||||||
|
if (!allowEmpty && value.length === 0) return fail(path, "must not be empty");
|
||||||
|
if (encoder.encode(value).byteLength > maxBytes) {
|
||||||
|
return fail(path, `must be at most ${maxBytes} UTF-8 bytes`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boolean(value: unknown, path: string): boolean {
|
||||||
|
if (typeof value !== "boolean") return fail(path, "must be a boolean");
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeInteger(value: unknown, path: string, minimum = 0): number {
|
||||||
|
if (
|
||||||
|
typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum
|
||||||
|
) {
|
||||||
|
return fail(path, `must be a safe integer of at least ${minimum}`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeRevision(value: unknown, path: string): number {
|
||||||
|
return safeInteger(value, path, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNullableString(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
maxBytes: number,
|
||||||
|
allowEmpty = false,
|
||||||
|
): string | null | undefined {
|
||||||
|
if (value === undefined || value === null) return value;
|
||||||
|
return boundedString(value, path, maxBytes, allowEmpty);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalRevision(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
): number | undefined {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
return safeRevision(value, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNullableRevision(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
): number | null | undefined {
|
||||||
|
if (value === undefined || value === null) return value;
|
||||||
|
return safeRevision(value, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function timestamp(value: unknown, path: string): string {
|
||||||
|
const result = boundedString(value, path, LIMITS.timestampBytes);
|
||||||
|
if (
|
||||||
|
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
||||||
|
.test(result)
|
||||||
|
) {
|
||||||
|
return fail(path, "must be an RFC 3339 timestamp");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function optionalNullableTimestamp(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
): string | null | undefined {
|
||||||
|
if (value === undefined || value === null) return value;
|
||||||
|
return timestamp(value, path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumValue<T extends string>(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
variants: ReadonlySet<T>,
|
||||||
|
): T {
|
||||||
|
const result = boundedString(value, path, LIMITS.kindBytes);
|
||||||
|
if (!variants.has(result as T)) {
|
||||||
|
return fail(path, "contains an unknown enum value");
|
||||||
|
}
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
function diagnostic(value: unknown, path: string): Diagnostic {
|
||||||
|
const item = object(value, path);
|
||||||
|
exactKeys(item, ["code", "severity", "message"], [], path);
|
||||||
|
const severity = enumValue(
|
||||||
|
item.severity,
|
||||||
|
`${path}.severity`,
|
||||||
|
DIAGNOSTIC_SEVERITIES,
|
||||||
|
) as Diagnostic["severity"];
|
||||||
|
return {
|
||||||
|
code: boundedString(item.code, `${path}.code`, LIMITS.diagnosticCodeBytes),
|
||||||
|
severity,
|
||||||
|
message: boundedString(
|
||||||
|
item.message,
|
||||||
|
`${path}.message`,
|
||||||
|
LIMITS.diagnosticMessageBytes,
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeSource(value: unknown, path: string): RuntimeSourceSummary {
|
||||||
|
const item = object(value, path);
|
||||||
|
exactKeys(item, ["kind", "status", "identity_authority", "note"], [], path);
|
||||||
|
return {
|
||||||
|
kind: enumValue(item.kind, `${path}.kind`, SOURCE_KINDS),
|
||||||
|
status: enumValue(item.status, `${path}.status`, SOURCE_STATUSES),
|
||||||
|
identity_authority: enumValue(
|
||||||
|
item.identity_authority,
|
||||||
|
`${path}.identity_authority`,
|
||||||
|
IDENTITY_AUTHORITIES,
|
||||||
|
),
|
||||||
|
note: boundedString(item.note, `${path}.note`, LIMITS.noteBytes, true),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeManagement(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
): RuntimeManagementSummary {
|
||||||
|
const item = object(value, path);
|
||||||
|
exactKeys(
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
"built_in",
|
||||||
|
"config_managed",
|
||||||
|
"removable",
|
||||||
|
"endpoint_configured",
|
||||||
|
"token_ref_configured",
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
built_in: boolean(item.built_in, `${path}.built_in`),
|
||||||
|
config_managed: boolean(item.config_managed, `${path}.config_managed`),
|
||||||
|
removable: boolean(item.removable, `${path}.removable`),
|
||||||
|
endpoint_configured: boolean(
|
||||||
|
item.endpoint_configured,
|
||||||
|
`${path}.endpoint_configured`,
|
||||||
|
),
|
||||||
|
token_ref_configured: boolean(
|
||||||
|
item.token_ref_configured,
|
||||||
|
`${path}.token_ref_configured`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtimeResource(
|
||||||
|
value: unknown,
|
||||||
|
path: string,
|
||||||
|
): WorkspaceRuntimeResource {
|
||||||
|
const item = object(value, path);
|
||||||
|
exactKeys(
|
||||||
|
item,
|
||||||
|
[
|
||||||
|
"management",
|
||||||
|
"runtime_id",
|
||||||
|
"label",
|
||||||
|
"kind",
|
||||||
|
"status",
|
||||||
|
"source",
|
||||||
|
"host_ids",
|
||||||
|
"worker_creation_available",
|
||||||
|
"os",
|
||||||
|
"arch",
|
||||||
|
"diagnostics",
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
const hostIds = array(item.host_ids, `${path}.host_ids`, LIMITS.hostIds).map(
|
||||||
|
(entry, index) =>
|
||||||
|
boundedString(
|
||||||
|
entry,
|
||||||
|
`${path}.host_ids[${index}]`,
|
||||||
|
LIMITS.idBytes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (new Set(hostIds).size !== hostIds.length) {
|
||||||
|
fail(`${path}.host_ids`, "must not contain duplicate IDs");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
management: runtimeManagement(item.management, `${path}.management`),
|
||||||
|
runtime_id: boundedString(
|
||||||
|
item.runtime_id,
|
||||||
|
`${path}.runtime_id`,
|
||||||
|
LIMITS.idBytes,
|
||||||
|
),
|
||||||
|
label: boundedString(item.label, `${path}.label`, LIMITS.labelBytes),
|
||||||
|
kind: boundedString(item.kind, `${path}.kind`, LIMITS.kindBytes),
|
||||||
|
status: boundedString(item.status, `${path}.status`, LIMITS.statusBytes),
|
||||||
|
source: runtimeSource(item.source, `${path}.source`),
|
||||||
|
host_ids: hostIds,
|
||||||
|
worker_creation_available: boolean(
|
||||||
|
item.worker_creation_available,
|
||||||
|
`${path}.worker_creation_available`,
|
||||||
|
),
|
||||||
|
os: boundedString(item.os, `${path}.os`, LIMITS.kindBytes, true),
|
||||||
|
arch: boundedString(item.arch, `${path}.arch`, LIMITS.kindBytes, true),
|
||||||
|
diagnostics: array(
|
||||||
|
item.diagnostics,
|
||||||
|
`${path}.diagnostics`,
|
||||||
|
LIMITS.diagnostics,
|
||||||
|
).map((entry, index) => diagnostic(entry, `${path}.diagnostics[${index}]`)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustKey(value: unknown, path: string): RuntimeTrustKeyState {
|
||||||
|
const item = object(value, path);
|
||||||
|
exactKeys(
|
||||||
|
item,
|
||||||
|
["status"],
|
||||||
|
["fingerprint", "revision", "created_at", "updated_at", "revoked_at"],
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
const result: RuntimeTrustKeyState = {
|
||||||
|
status: enumValue(item.status, `${path}.status`, TRUST_STATUSES),
|
||||||
|
fingerprint: optionalNullableString(
|
||||||
|
item.fingerprint,
|
||||||
|
`${path}.fingerprint`,
|
||||||
|
LIMITS.fingerprintBytes,
|
||||||
|
),
|
||||||
|
revision: optionalNullableRevision(item.revision, `${path}.revision`),
|
||||||
|
created_at: optionalNullableTimestamp(
|
||||||
|
item.created_at,
|
||||||
|
`${path}.created_at`,
|
||||||
|
),
|
||||||
|
updated_at: optionalNullableTimestamp(
|
||||||
|
item.updated_at,
|
||||||
|
`${path}.updated_at`,
|
||||||
|
),
|
||||||
|
revoked_at: optionalNullableTimestamp(
|
||||||
|
item.revoked_at,
|
||||||
|
`${path}.revoked_at`,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasBinding = result.status !== "unconfigured";
|
||||||
|
if (
|
||||||
|
hasBinding &&
|
||||||
|
(result.fingerprint == null || result.revision == null ||
|
||||||
|
result.created_at == null || result.updated_at == null)
|
||||||
|
) {
|
||||||
|
fail(
|
||||||
|
path,
|
||||||
|
"must include fingerprint, revision, created_at, and updated_at",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!hasBinding &&
|
||||||
|
Object.entries(result).some(([key, entry]) =>
|
||||||
|
key !== "status" && entry != null
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
fail(path, "must not include binding values while unconfigured");
|
||||||
|
}
|
||||||
|
if (result.status === "revoked" && result.revoked_at == null) {
|
||||||
|
fail(`${path}.revoked_at`, "is required for a revoked key");
|
||||||
|
}
|
||||||
|
if (result.status === "active" && result.revoked_at != null) {
|
||||||
|
fail(`${path}.revoked_at`, "must be absent for an active key");
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry {
|
||||||
|
const item = object(value, path);
|
||||||
|
exactKeys(
|
||||||
|
item,
|
||||||
|
["action", "actor_account_id", "revision", "at"],
|
||||||
|
["old_fingerprint", "new_fingerprint"],
|
||||||
|
path,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
action: enumValue(item.action, `${path}.action`, AUDIT_ACTIONS),
|
||||||
|
actor_account_id: boundedString(
|
||||||
|
item.actor_account_id,
|
||||||
|
`${path}.actor_account_id`,
|
||||||
|
LIMITS.idBytes,
|
||||||
|
),
|
||||||
|
old_fingerprint: optionalNullableString(
|
||||||
|
item.old_fingerprint,
|
||||||
|
`${path}.old_fingerprint`,
|
||||||
|
LIMITS.fingerprintBytes,
|
||||||
|
),
|
||||||
|
new_fingerprint: optionalNullableString(
|
||||||
|
item.new_fingerprint,
|
||||||
|
`${path}.new_fingerprint`,
|
||||||
|
LIMITS.fingerprintBytes,
|
||||||
|
),
|
||||||
|
revision: safeRevision(item.revision, `${path}.revision`),
|
||||||
|
at: timestamp(item.at, `${path}.at`),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkspaceRuntimeList(
|
||||||
|
value: unknown,
|
||||||
|
): WorkspaceRuntimeList {
|
||||||
|
const response = object(value, "Runtime list response");
|
||||||
|
exactKeys(
|
||||||
|
response,
|
||||||
|
["workspace_id", "limit", "items", "source", "diagnostics"],
|
||||||
|
[],
|
||||||
|
"Runtime list response",
|
||||||
|
);
|
||||||
|
const limit = safeInteger(response.limit, "Runtime list response.limit", 0);
|
||||||
|
if (limit > LIMITS.runtimeItems) {
|
||||||
|
fail(
|
||||||
|
"Runtime list response.limit",
|
||||||
|
`must not exceed ${LIMITS.runtimeItems}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const items = array(
|
||||||
|
response.items,
|
||||||
|
"Runtime list response.items",
|
||||||
|
LIMITS.runtimeItems,
|
||||||
|
).map((entry, index) =>
|
||||||
|
runtimeResource(entry, `Runtime list response.items[${index}]`)
|
||||||
|
);
|
||||||
|
if (items.length > limit) {
|
||||||
|
fail("Runtime list response.items", "must not exceed the declared limit");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
workspace_id: boundedString(
|
||||||
|
response.workspace_id,
|
||||||
|
"Runtime list response.workspace_id",
|
||||||
|
LIMITS.idBytes,
|
||||||
|
),
|
||||||
|
limit,
|
||||||
|
items,
|
||||||
|
source: boundedString(
|
||||||
|
response.source,
|
||||||
|
"Runtime list response.source",
|
||||||
|
LIMITS.kindBytes,
|
||||||
|
),
|
||||||
|
diagnostics: array(
|
||||||
|
response.diagnostics,
|
||||||
|
"Runtime list response.diagnostics",
|
||||||
|
LIMITS.diagnostics,
|
||||||
|
).map((entry, index) =>
|
||||||
|
diagnostic(entry, `Runtime list response.diagnostics[${index}]`)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseWorkspaceRuntimeDetail(
|
||||||
|
value: unknown,
|
||||||
|
): WorkspaceRuntimeDetail {
|
||||||
|
const response = object(value, "Runtime detail response");
|
||||||
|
exactKeys(
|
||||||
|
response,
|
||||||
|
["workspace_id", "runtime", "trust_key", "recent_audit"],
|
||||||
|
["endpoint"],
|
||||||
|
"Runtime detail response",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
workspace_id: boundedString(
|
||||||
|
response.workspace_id,
|
||||||
|
"Runtime detail response.workspace_id",
|
||||||
|
LIMITS.idBytes,
|
||||||
|
),
|
||||||
|
runtime: runtimeResource(
|
||||||
|
response.runtime,
|
||||||
|
"Runtime detail response.runtime",
|
||||||
|
),
|
||||||
|
endpoint: optionalNullableString(
|
||||||
|
response.endpoint,
|
||||||
|
"Runtime detail response.endpoint",
|
||||||
|
LIMITS.endpointBytes,
|
||||||
|
),
|
||||||
|
trust_key: trustKey(
|
||||||
|
response.trust_key,
|
||||||
|
"Runtime detail response.trust_key",
|
||||||
|
),
|
||||||
|
recent_audit: array(
|
||||||
|
response.recent_audit,
|
||||||
|
"Runtime detail response.recent_audit",
|
||||||
|
LIMITS.auditEntries,
|
||||||
|
).map((entry, index) =>
|
||||||
|
auditEntry(entry, `Runtime detail response.recent_audit[${index}]`)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRuntimeTrustKeyRevealResponse(
|
||||||
|
value: unknown,
|
||||||
|
): RuntimeTrustKeyRevealResponse {
|
||||||
|
const response = object(value, "Runtime trust key reveal response");
|
||||||
|
exactKeys(
|
||||||
|
response,
|
||||||
|
["public_key"],
|
||||||
|
[],
|
||||||
|
"Runtime trust key reveal response",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
public_key: boundedString(
|
||||||
|
response.public_key,
|
||||||
|
"Runtime trust key reveal response.public_key",
|
||||||
|
LIMITS.publicKeyBytes,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseRuntimeTrustConflict(
|
||||||
|
value: unknown,
|
||||||
|
): RuntimeTrustConflictResponse {
|
||||||
|
const response = object(value, "Runtime trust conflict");
|
||||||
|
exactKeys(
|
||||||
|
response,
|
||||||
|
["error", "message"],
|
||||||
|
["current_revision", "current_fingerprint"],
|
||||||
|
"Runtime trust conflict",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
error: enumValue(
|
||||||
|
response.error,
|
||||||
|
"Runtime trust conflict.error",
|
||||||
|
CONFLICT_KINDS,
|
||||||
|
),
|
||||||
|
message: boundedString(
|
||||||
|
response.message,
|
||||||
|
"Runtime trust conflict.message",
|
||||||
|
LIMITS.conflictMessageBytes,
|
||||||
|
),
|
||||||
|
current_revision: optionalRevision(
|
||||||
|
response.current_revision,
|
||||||
|
"Runtime trust conflict.current_revision",
|
||||||
|
),
|
||||||
|
current_fingerprint: optionalNullableString(
|
||||||
|
response.current_fingerprint,
|
||||||
|
"Runtime trust conflict.current_fingerprint",
|
||||||
|
LIMITS.fingerprintBytes,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function revisionForJson(revision: number | null): number | null {
|
||||||
|
if (revision === null) return null;
|
||||||
|
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||||
|
throw new RuntimeTrustRequestError(
|
||||||
|
"Runtime trust revision is not a safe integer",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return revision;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readBoundedJson(response: Response): Promise<unknown> {
|
||||||
|
const contentLength = response.headers.get("content-length");
|
||||||
|
if (contentLength !== null) {
|
||||||
|
const parsed = Number(contentLength);
|
||||||
|
if (Number.isFinite(parsed) && parsed > LIMITS.responseBytes) {
|
||||||
|
throw new RuntimeTrustRequestError(
|
||||||
|
"Runtime trust response exceeds its byte limit",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const text = await response.text();
|
||||||
|
if (encoder.encode(text).byteLength > LIMITS.responseBytes) {
|
||||||
|
throw new RuntimeTrustRequestError(
|
||||||
|
"Runtime trust response exceeds its byte limit",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as unknown;
|
||||||
|
} catch {
|
||||||
|
throw new RuntimeTrustRequestError(
|
||||||
|
"Runtime trust response is not valid JSON",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestErrorFrom(
|
||||||
|
value: unknown,
|
||||||
|
status: number,
|
||||||
|
): RuntimeTrustRequestError {
|
||||||
|
try {
|
||||||
|
const response = object(value, "Runtime trust error");
|
||||||
|
exactKeys(
|
||||||
|
response,
|
||||||
|
["error", "message", "diagnostics"],
|
||||||
|
[],
|
||||||
|
"Runtime trust error",
|
||||||
|
);
|
||||||
|
const diagnostics = array(
|
||||||
|
response.diagnostics,
|
||||||
|
"Runtime trust error.diagnostics",
|
||||||
|
LIMITS.diagnostics,
|
||||||
|
).map((entry, index) =>
|
||||||
|
diagnostic(entry, `Runtime trust error.diagnostics[${index}]`)
|
||||||
|
);
|
||||||
|
const message = boundedString(
|
||||||
|
response.message,
|
||||||
|
"Runtime trust error.message",
|
||||||
|
LIMITS.conflictMessageBytes,
|
||||||
|
);
|
||||||
|
const field = diagnostics.some((entry) =>
|
||||||
|
entry.code.startsWith("runtime_public_key_")
|
||||||
|
)
|
||||||
|
? "public_key"
|
||||||
|
: null;
|
||||||
|
return new RuntimeTrustRequestError(message, field);
|
||||||
|
} catch {
|
||||||
|
return new RuntimeTrustRequestError(
|
||||||
|
`Runtime trust request failed (${status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finishMutation(
|
||||||
|
response: Response,
|
||||||
|
workspaceId: string,
|
||||||
|
runtimeId: string,
|
||||||
|
): Promise<WorkspaceRuntimeDetail> {
|
||||||
|
const payload = await readBoundedJson(response);
|
||||||
|
if (response.status === 409) {
|
||||||
|
try {
|
||||||
|
throw new RuntimeTrustConflictError(parseRuntimeTrustConflict(payload));
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof RuntimeTrustConflictError) throw error;
|
||||||
|
throw new RuntimeTrustRequestError(
|
||||||
|
"Runtime trust conflict response was invalid",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
||||||
|
let detail: WorkspaceRuntimeDetail;
|
||||||
|
try {
|
||||||
|
detail = parseWorkspaceRuntimeDetail(payload);
|
||||||
|
} catch {
|
||||||
|
throw new RuntimeTrustRequestError("Runtime trust response was invalid");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
detail.workspace_id !== workspaceId ||
|
||||||
|
detail.runtime.runtime_id !== runtimeId
|
||||||
|
) {
|
||||||
|
throw new RuntimeTrustRequestError(
|
||||||
|
"Runtime trust response did not match the selected Runtime",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revealRuntimeTrustKey(
|
||||||
|
workspaceId: string,
|
||||||
|
runtimeId: string,
|
||||||
|
): Promise<RuntimeTrustKeyRevealResponse> {
|
||||||
|
const response = await fetch(
|
||||||
|
workspaceApiPath(
|
||||||
|
workspaceId,
|
||||||
|
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const payload = await readBoundedJson(response);
|
||||||
|
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
||||||
|
return parseRuntimeTrustKeyRevealResponse(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function previewRuntimePublicKeyFingerprint(
|
||||||
|
publicKey: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const normalized = publicKey.trim();
|
||||||
|
const prefix = "yoi-ed25519-pub:v1:";
|
||||||
|
if (!normalized.startsWith(prefix)) {
|
||||||
|
throw new RuntimeTrustRequestError(
|
||||||
|
`Public key must start with ${prefix}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const encoded = normalized.slice(prefix.length);
|
||||||
|
if (!/^[A-Za-z0-9_-]+$/.test(encoded)) {
|
||||||
|
throw new RuntimeTrustRequestError("Public key encoding is invalid");
|
||||||
|
}
|
||||||
|
const padded = encoded.replaceAll("-", "+").replaceAll("_", "/") +
|
||||||
|
"=".repeat((4 - (encoded.length % 4)) % 4);
|
||||||
|
let decoded: string;
|
||||||
|
try {
|
||||||
|
decoded = atob(padded);
|
||||||
|
} catch {
|
||||||
|
throw new RuntimeTrustRequestError("Public key encoding is invalid");
|
||||||
|
}
|
||||||
|
if (decoded.length !== 32) {
|
||||||
|
throw new RuntimeTrustRequestError("Public key must contain 32 bytes");
|
||||||
|
}
|
||||||
|
const bytes = Uint8Array.from(
|
||||||
|
decoded,
|
||||||
|
(character) => character.charCodeAt(0),
|
||||||
|
);
|
||||||
|
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes));
|
||||||
|
const hex = Array.from(digest, (byte) => byte.toString(16).padStart(2, "0"))
|
||||||
|
.join("");
|
||||||
|
return `sha256:${hex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function putRuntimeTrustKey(
|
||||||
|
workspaceId: string,
|
||||||
|
runtimeId: string,
|
||||||
|
request: PutRuntimeTrustKeyRequest,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<WorkspaceRuntimeDetail> {
|
||||||
|
const response = await fetchImpl(
|
||||||
|
workspaceApiPath(
|
||||||
|
workspaceId,
|
||||||
|
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
public_key: request.public_key,
|
||||||
|
expected_revision: revisionForJson(request.expected_revision),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return await finishMutation(response, workspaceId, runtimeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeRuntimeTrustKey(
|
||||||
|
workspaceId: string,
|
||||||
|
runtimeId: string,
|
||||||
|
request: RevokeRuntimeTrustKeyRequest,
|
||||||
|
currentFingerprint: string,
|
||||||
|
confirmation: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<WorkspaceRuntimeDetail> {
|
||||||
|
if (!currentFingerprint || confirmation.trim() !== currentFingerprint) {
|
||||||
|
throw new RuntimeTrustRequestError(
|
||||||
|
"Enter the current fingerprint exactly before revoking Workspace trust.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const response = await fetchImpl(
|
||||||
|
workspaceApiPath(
|
||||||
|
workspaceId,
|
||||||
|
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
expected_revision: revisionForJson(request.expected_revision),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return await finishMutation(response, workspaceId, runtimeId);
|
||||||
|
}
|
||||||
@@ -367,13 +367,18 @@ function authConfig(value: unknown, path: string): WorkspaceAuthConfig {
|
|||||||
|
|
||||||
function permissions(value: unknown, path: string): WorkspacePermissionSummary {
|
function permissions(value: unknown, path: string): WorkspacePermissionSummary {
|
||||||
const item = object(value, path);
|
const item = object(value, path);
|
||||||
exactKeys(item, ["manage_repositories", "manage_secrets"], path);
|
exactKeys(
|
||||||
|
item,
|
||||||
|
["manage_repositories", "manage_secrets", "manage_runtimes"],
|
||||||
|
path,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
manage_repositories: boolean(
|
manage_repositories: boolean(
|
||||||
item.manage_repositories,
|
item.manage_repositories,
|
||||||
`${path}.manage_repositories`,
|
`${path}.manage_repositories`,
|
||||||
),
|
),
|
||||||
manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`),
|
manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`),
|
||||||
|
manage_runtimes: boolean(item.manage_runtimes, `${path}.manage_runtimes`),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -620,7 +620,7 @@ Deno.test("Worker Console paste chips preserve typed draft and target authority"
|
|||||||
consolePage.includes("preserveExactText: value.textPastes.length > 0") &&
|
consolePage.includes("preserveExactText: value.textPastes.length > 0") &&
|
||||||
consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
|
consolePage.includes("composerDrafts.set(activeComposerTargetKey") &&
|
||||||
consolePage.includes("switchComposerTarget(target)") &&
|
consolePage.includes("switchComposerTarget(target)") &&
|
||||||
consolePage.includes('sendControl({ method: "cancel" }, "Stop")'),
|
consolePage.includes('sendWorkerControl("cancel")'),
|
||||||
"Paste chips should use shared threshold classification, atomic keyboard behavior, accessible labels, typed restore, and per-Worker draft authority",
|
"Paste chips should use shared threshold classification, atomic keyboard behavior, accessible labels, typed restore, and per-Worker draft authority",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -787,7 +787,10 @@ Deno.test("Worker Console page is routed by runtime_id and worker_id through bac
|
|||||||
consolePage.includes(
|
consolePage.includes(
|
||||||
'const composerEditable = $derived(protocolState === "open" && !sending);',
|
'const composerEditable = $derived(protocolState === "open" && !sending);',
|
||||||
) &&
|
) &&
|
||||||
consolePage.includes('sendControl({ method: "cancel" }, "Stop")') &&
|
consolePage.includes('sendWorkerControl("cancel")') &&
|
||||||
|
consolePage.includes("lifecycleMethod(command)") &&
|
||||||
|
consolePage.includes("expected_worker_state_revision") &&
|
||||||
|
consolePage.includes("expected_execution_generation") &&
|
||||||
consolePage.includes("onsubmit={handleComposerSubmit}") &&
|
consolePage.includes("onsubmit={handleComposerSubmit}") &&
|
||||||
consolePage.includes("disabled={!composerEditable}") &&
|
consolePage.includes("disabled={!composerEditable}") &&
|
||||||
consolePage.includes("class:stop={workerRunning}") &&
|
consolePage.includes("class:stop={workerRunning}") &&
|
||||||
|
|||||||
@@ -342,6 +342,224 @@
|
|||||||
.settings-test-result.failed {
|
.settings-test-result.failed {
|
||||||
border-inline-start: 3px solid var(--danger);
|
border-inline-start: 3px solid var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.runtime-detail-page {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-detail-section {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding-top: var(--space-4);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-detail-section h2,
|
||||||
|
.runtime-detail-section p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-detail-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
|
||||||
|
gap: var(--space-3) var(--space-5);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-detail-grid div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-detail-grid dt {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-detail-grid dd {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-strong);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-public-key-actions,
|
||||||
|
.runtime-revoke-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-3);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-public-key-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-public-key-actions button,
|
||||||
|
.runtime-revoke-row button,
|
||||||
|
.runtime-trust-form button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0.6rem;
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: var(--bg);
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-public-key-actions button.secondary {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-public-key-actions button:disabled,
|
||||||
|
.runtime-revoke-row button:disabled,
|
||||||
|
.runtime-trust-form button:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-public-key,
|
||||||
|
.runtime-trust-form textarea,
|
||||||
|
.runtime-trust-form input,
|
||||||
|
.runtime-revoke-row input {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--bg-raised);
|
||||||
|
color: var(--text-strong);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-public-key {
|
||||||
|
max-height: 14rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3);
|
||||||
|
overflow: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-trust-form {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
max-width: 56rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-trust-form label,
|
||||||
|
.runtime-revoke-row label {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-trust-form textarea,
|
||||||
|
.runtime-trust-form input,
|
||||||
|
.runtime-revoke-row input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.65rem 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-trust-form textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-trust-form small,
|
||||||
|
.runtime-revoke-row small {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-trust-comparison {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
padding: var(--space-3) 0;
|
||||||
|
border-block: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-trust-comparison div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-trust-comparison dt {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-trust-comparison dd {
|
||||||
|
margin: var(--space-1) 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-trust-form .field-error,
|
||||||
|
.runtime-detail-page .section-state.error {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-detail-page .section-state.success {
|
||||||
|
color: var(--success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-revoke-row {
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-revoke-row div {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-revoke-row p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-revoke-row button.danger {
|
||||||
|
background: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-audit-table-wrap {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-audit-table {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 48rem;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-audit-table th,
|
||||||
|
.runtime-audit-table td {
|
||||||
|
padding: 0.7rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-audit-table th {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.runtime-audit-table code {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.runtime-revoke-row {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.settings-page {
|
.settings-page {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: var(--space-5);
|
gap: var(--space-5);
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { invalidateAll } from '$app/navigation';
|
import { invalidateAll } from '$app/navigation';
|
||||||
import type { RuntimeConnectionTestResponse } from '$lib/generated/workspace-api';
|
import type {
|
||||||
|
RuntimeConnectionTestResponse,
|
||||||
|
WorkspaceRuntimeResource,
|
||||||
|
} from '$lib/generated/workspace-api';
|
||||||
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
|
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
|
||||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||||
import type { Runtime } from '$lib/workspace/sidebar/types';
|
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -15,7 +17,7 @@
|
|||||||
let requestError = $state<string | null>(null);
|
let requestError = $state<string | null>(null);
|
||||||
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
|
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
|
||||||
|
|
||||||
function runtimePlatform(runtime: Runtime): string {
|
function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
|
||||||
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
|
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +40,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function managementLabel(runtime: Runtime): string {
|
function managementLabel(runtime: WorkspaceRuntimeResource): string {
|
||||||
if (runtime.management?.built_in) return 'Built-in';
|
if (runtime.management?.built_in) return 'Built-in';
|
||||||
if (runtime.management?.config_managed) return 'Managed remote';
|
if (runtime.management?.config_managed) return 'Managed remote';
|
||||||
return 'Observed';
|
return 'Observed';
|
||||||
@@ -78,27 +80,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteRuntime(runtime: Runtime): Promise<void> {
|
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
|
||||||
requestError = null;
|
|
||||||
busyRuntimeId = runtime.runtime_id;
|
|
||||||
try {
|
|
||||||
const response = await fetch(
|
|
||||||
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtime.runtime_id)}`),
|
|
||||||
{ method: 'DELETE' },
|
|
||||||
);
|
|
||||||
if (!response.ok) throw new Error(await responseError(response));
|
|
||||||
const nextResults = { ...testResults };
|
|
||||||
delete nextResults[runtime.runtime_id];
|
|
||||||
testResults = nextResults;
|
|
||||||
await invalidateAll();
|
|
||||||
} catch (error) {
|
|
||||||
requestError = error instanceof Error ? error.message : String(error);
|
|
||||||
} finally {
|
|
||||||
busyRuntimeId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testRuntime(runtime: Runtime): Promise<void> {
|
|
||||||
requestError = null;
|
requestError = null;
|
||||||
busyRuntimeId = runtime.runtime_id;
|
busyRuntimeId = runtime.runtime_id;
|
||||||
try {
|
try {
|
||||||
@@ -123,12 +105,14 @@
|
|||||||
<h1 id="runtimes-heading">Runtimes</h1>
|
<h1 id="runtimes-heading">Runtimes</h1>
|
||||||
<p>Register and inspect the execution backends available to this Workspace.</p>
|
<p>Register and inspect the execution backends available to this Workspace.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
|
{#if data.workspace.permissions.manage_runtimes}
|
||||||
{showAddRuntime ? 'Close' : 'Add Runtime'}
|
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
|
||||||
</button>
|
{showAddRuntime ? 'Close' : 'Add Runtime'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{#if showAddRuntime}
|
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
|
||||||
<form class="settings-runtime-form" onsubmit={addRuntime}>
|
<form class="settings-runtime-form" onsubmit={addRuntime}>
|
||||||
<h2>Add remote Runtime</h2>
|
<h2>Add remote Runtime</h2>
|
||||||
<div class="settings-form-grid">
|
<div class="settings-form-grid">
|
||||||
@@ -182,7 +166,11 @@
|
|||||||
{#each data.runtimes.items as runtime}
|
{#each data.runtimes.items as runtime}
|
||||||
<tr class:inactive={runtime.status !== 'active'}>
|
<tr class:inactive={runtime.status !== 'active'}>
|
||||||
<td>
|
<td>
|
||||||
<strong>{runtime.label}</strong>
|
<strong>
|
||||||
|
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}`}>
|
||||||
|
{runtime.label}
|
||||||
|
</a>
|
||||||
|
</strong>
|
||||||
<small><code>{runtime.runtime_id}</code></small>
|
<small><code>{runtime.runtime_id}</code></small>
|
||||||
</td>
|
</td>
|
||||||
<td>{runtime.kind}</td>
|
<td>{runtime.kind}</td>
|
||||||
@@ -203,15 +191,8 @@
|
|||||||
onclick={() => testRuntime(runtime)}
|
onclick={() => testRuntime(runtime)}
|
||||||
>Test</button>
|
>Test</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if runtime.management?.removable}
|
{#if !runtime.management?.config_managed}
|
||||||
<button
|
<span class="settings-muted-action">Test unavailable</span>
|
||||||
class="danger"
|
|
||||||
type="button"
|
|
||||||
disabled={busyRuntimeId !== null}
|
|
||||||
onclick={() => deleteRuntime(runtime)}
|
|
||||||
>Delete</button>
|
|
||||||
{:else}
|
|
||||||
<span class="settings-muted-action">Not removable</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import type { ListResponse, Runtime } from "$lib/workspace/sidebar/types";
|
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
const runtimes = await loadJson<ListResponse<Runtime>>(
|
const runtimes = await loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(params.workspaceId, "/runtimes"),
|
workspaceApiPath(params.workspaceId, "/runtimes"),
|
||||||
|
undefined,
|
||||||
|
(value) => {
|
||||||
|
const response = parseWorkspaceRuntimeList(value);
|
||||||
|
if (response.workspace_id !== params.workspaceId) {
|
||||||
|
throw new Error("Runtime list Workspace did not match the route");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,471 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import type {
|
||||||
|
PutRuntimeTrustKeyRequest,
|
||||||
|
RevokeRuntimeTrustKeyRequest,
|
||||||
|
RuntimeTrustKeyStatus,
|
||||||
|
} from '$lib/generated/workspace-api';
|
||||||
|
import {
|
||||||
|
previewRuntimePublicKeyFingerprint,
|
||||||
|
putRuntimeTrustKey,
|
||||||
|
revealRuntimeTrustKey,
|
||||||
|
revokeRuntimeTrustKey,
|
||||||
|
RuntimeTrustConflictError,
|
||||||
|
RuntimeTrustRouteFence,
|
||||||
|
RuntimeTrustRequestError,
|
||||||
|
type RuntimeTrustRouteOperation,
|
||||||
|
} from '$lib/workspace/api/runtime-management';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
type TrustAction = 'create' | 'replace' | 'reactivate';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
let showPublicKey = $state(false);
|
||||||
|
let revealedPublicKey = $state<string | null>(null);
|
||||||
|
let publicKey = $state('');
|
||||||
|
let fingerprintConfirmation = $state('');
|
||||||
|
let revokeFingerprintConfirmation = $state('');
|
||||||
|
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | null>(null);
|
||||||
|
let fieldError = $state<string | null>(null);
|
||||||
|
let requestError = $state<string | null>(null);
|
||||||
|
let successMessage = $state<string | null>(null);
|
||||||
|
let replacementFingerprint = $state<string | null>(null);
|
||||||
|
let replacementFingerprintError = $state<string | null>(null);
|
||||||
|
let fingerprintGeneration = 0;
|
||||||
|
const routeFence = new RuntimeTrustRouteFence();
|
||||||
|
let routeGeneration = 0;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const nextGeneration = routeFence.enter(data.runtimeId);
|
||||||
|
if (nextGeneration === routeGeneration) return;
|
||||||
|
routeGeneration = nextGeneration;
|
||||||
|
fingerprintGeneration += 1;
|
||||||
|
showPublicKey = false;
|
||||||
|
revealedPublicKey = null;
|
||||||
|
publicKey = '';
|
||||||
|
fingerprintConfirmation = '';
|
||||||
|
revokeFingerprintConfirmation = '';
|
||||||
|
busyAction = null;
|
||||||
|
fieldError = null;
|
||||||
|
requestError = null;
|
||||||
|
successMessage = null;
|
||||||
|
replacementFingerprint = null;
|
||||||
|
replacementFingerprintError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const key = publicKey.trim();
|
||||||
|
const generation = ++fingerprintGeneration;
|
||||||
|
replacementFingerprint = null;
|
||||||
|
replacementFingerprintError = null;
|
||||||
|
if (!key) return;
|
||||||
|
void previewRuntimePublicKeyFingerprint(key).then(
|
||||||
|
(fingerprint) => {
|
||||||
|
if (generation === fingerprintGeneration) replacementFingerprint = fingerprint;
|
||||||
|
},
|
||||||
|
(error) => {
|
||||||
|
if (generation === fingerprintGeneration) {
|
||||||
|
replacementFingerprintError = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: String(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function trustAction(status: RuntimeTrustKeyStatus): TrustAction {
|
||||||
|
if (status === 'unconfigured') return 'create';
|
||||||
|
if (status === 'revoked') return 'reactivate';
|
||||||
|
return 'replace';
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionLabel(action: TrustAction): string {
|
||||||
|
switch (action) {
|
||||||
|
case 'create': return 'Create Workspace trust';
|
||||||
|
case 'replace': return 'Replace trusted key';
|
||||||
|
case 'reactivate': return 'Reactivate with this key';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimestamp(value: string | null | undefined): string {
|
||||||
|
if (!value) return '—';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function utf8Bytes(value: string): number {
|
||||||
|
return new TextEncoder().encode(value).byteLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadAuthority(): Promise<void> {
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isCurrentRoute(operation: RuntimeTrustRouteOperation): boolean {
|
||||||
|
return routeFence.isCurrent(operation, data.runtimeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveTrustKey(event: SubmitEvent): Promise<void> {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busyAction !== null || !data.runtimeDetail) return;
|
||||||
|
|
||||||
|
fieldError = null;
|
||||||
|
requestError = null;
|
||||||
|
successMessage = null;
|
||||||
|
|
||||||
|
const key = publicKey.trim();
|
||||||
|
if (!key) {
|
||||||
|
fieldError = 'Enter the Runtime public key.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (utf8Bytes(key) > 16 * 1024) {
|
||||||
|
fieldError = 'Public key must be at most 16 KiB of UTF-8 text.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (replacementFingerprintError) {
|
||||||
|
fieldError = replacementFingerprintError;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!replacementFingerprint) {
|
||||||
|
fieldError = 'Wait for the replacement fingerprint preview before saving.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trust = data.runtimeDetail.trust_key;
|
||||||
|
const action = trustAction(trust.status);
|
||||||
|
if (action !== 'create') {
|
||||||
|
if (!trust.fingerprint) {
|
||||||
|
requestError = 'The authoritative fingerprint is unavailable. Reload before changing trust.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (fingerprintConfirmation.trim() !== trust.fingerprint) {
|
||||||
|
fieldError = 'Enter the current fingerprint exactly to confirm this change.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const request: PutRuntimeTrustKeyRequest = {
|
||||||
|
public_key: key,
|
||||||
|
expected_revision: trust.revision ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
|
busyAction = 'save';
|
||||||
|
try {
|
||||||
|
await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request);
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
publicKey = '';
|
||||||
|
fingerprintConfirmation = '';
|
||||||
|
revokeFingerprintConfirmation = '';
|
||||||
|
showPublicKey = false;
|
||||||
|
revealedPublicKey = null;
|
||||||
|
successMessage = action === 'create'
|
||||||
|
? 'Workspace trust was created.'
|
||||||
|
: action === 'replace'
|
||||||
|
? 'The trusted Runtime key was replaced.'
|
||||||
|
: 'Workspace trust was reactivated.';
|
||||||
|
await reloadAuthority();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
fingerprintConfirmation = '';
|
||||||
|
if (error instanceof RuntimeTrustConflictError) {
|
||||||
|
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
||||||
|
await reloadAuthority();
|
||||||
|
} else if (error instanceof RuntimeTrustRequestError && error.field === 'public_key') {
|
||||||
|
fieldError = error.message;
|
||||||
|
} else {
|
||||||
|
requestError = error instanceof Error ? error.message : 'Runtime trust update failed.';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isCurrentRoute(operation)) busyAction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeTrust(): Promise<void> {
|
||||||
|
if (busyAction !== null || !data.runtimeDetail) return;
|
||||||
|
const trust = data.runtimeDetail.trust_key;
|
||||||
|
if (trust.revision == null || trust.status !== 'active') {
|
||||||
|
requestError = 'Only active Workspace trust can be revoked.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!trust.fingerprint ||
|
||||||
|
revokeFingerprintConfirmation.trim() !== trust.fingerprint
|
||||||
|
) {
|
||||||
|
fieldError = 'Enter the current fingerprint exactly before revoking Workspace trust.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldError = null;
|
||||||
|
requestError = null;
|
||||||
|
successMessage = null;
|
||||||
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
|
busyAction = 'revoke';
|
||||||
|
const request: RevokeRuntimeTrustKeyRequest = {
|
||||||
|
expected_revision: trust.revision,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await revokeRuntimeTrustKey(
|
||||||
|
data.workspaceId,
|
||||||
|
operation.runtimeId,
|
||||||
|
request,
|
||||||
|
trust.fingerprint,
|
||||||
|
revokeFingerprintConfirmation,
|
||||||
|
);
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
publicKey = '';
|
||||||
|
fingerprintConfirmation = '';
|
||||||
|
revokeFingerprintConfirmation = '';
|
||||||
|
showPublicKey = false;
|
||||||
|
revealedPublicKey = null;
|
||||||
|
successMessage = 'Workspace trust was revoked.';
|
||||||
|
await reloadAuthority();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
if (error instanceof RuntimeTrustConflictError) {
|
||||||
|
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
||||||
|
await reloadAuthority();
|
||||||
|
} else {
|
||||||
|
requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.';
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isCurrentRoute(operation)) busyAction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePublicKeyReveal(): Promise<void> {
|
||||||
|
if (showPublicKey) {
|
||||||
|
showPublicKey = false;
|
||||||
|
revealedPublicKey = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (busyAction !== null) return;
|
||||||
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
|
busyAction = 'reveal';
|
||||||
|
requestError = null;
|
||||||
|
successMessage = null;
|
||||||
|
try {
|
||||||
|
const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId);
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
revealedPublicKey = response.public_key;
|
||||||
|
showPublicKey = true;
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
requestError = error instanceof Error ? error.message : 'Public key reveal failed.';
|
||||||
|
} finally {
|
||||||
|
if (isCurrentRoute(operation)) busyAction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyPublicKey(): Promise<void> {
|
||||||
|
if (busyAction !== null) return;
|
||||||
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
|
busyAction = 'copy';
|
||||||
|
requestError = null;
|
||||||
|
successMessage = null;
|
||||||
|
try {
|
||||||
|
const response = await revealRuntimeTrustKey(data.workspaceId, operation.runtimeId);
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
await navigator.clipboard.writeText(response.public_key);
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
successMessage = 'Public key copied.';
|
||||||
|
} catch (error) {
|
||||||
|
if (!isCurrentRoute(operation)) return;
|
||||||
|
requestError = error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: 'The browser could not copy the public key.';
|
||||||
|
} finally {
|
||||||
|
if (isCurrentRoute(operation)) busyAction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{data.runtimeDetail?.runtime.label ?? data.runtimeId} · Runtime Settings · Yoi Workspace</title>
|
||||||
|
<meta name="description" content="Runtime identity and Workspace trust settings" />
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<section class="runtime-detail-page" aria-labelledby="runtime-detail-heading">
|
||||||
|
<header class="page-header-row">
|
||||||
|
<div>
|
||||||
|
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`}>Runtimes</a>
|
||||||
|
<h1 id="runtime-detail-heading">{data.runtimeDetail?.runtime.label ?? data.runtimeId}</h1>
|
||||||
|
<p><code>{data.runtimeId}</code></p>
|
||||||
|
</div>
|
||||||
|
<a class="button-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(data.runtimeId)}/workdirs`}>
|
||||||
|
Workdirs
|
||||||
|
</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{#if data.runtimeDetailError}
|
||||||
|
<p class="section-state error">{data.runtimeDetailError}</p>
|
||||||
|
{:else if !data.runtimeDetail}
|
||||||
|
<p class="section-state">Loading Runtime…</p>
|
||||||
|
{:else}
|
||||||
|
{@const detail = data.runtimeDetail}
|
||||||
|
{@const runtime = detail.runtime}
|
||||||
|
{@const trust = detail.trust_key}
|
||||||
|
{@const currentAction = trustAction(trust.status)}
|
||||||
|
|
||||||
|
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
|
||||||
|
<h2 id="runtime-identity-heading">Identity and binding</h2>
|
||||||
|
<dl class="runtime-detail-grid">
|
||||||
|
<div><dt>Runtime ID</dt><dd><code>{runtime.runtime_id}</code></dd></div>
|
||||||
|
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
|
||||||
|
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
|
||||||
|
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
|
||||||
|
<div><dt>Binding status</dt><dd>{trust.status}</dd></div>
|
||||||
|
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
|
||||||
|
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
|
||||||
|
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
|
||||||
|
<div><dt>Updated</dt><dd>{formatTimestamp(trust.updated_at)}</dd></div>
|
||||||
|
<div><dt>Revoked</dt><dd>{formatTimestamp(trust.revoked_at)}</dd></div>
|
||||||
|
</dl>
|
||||||
|
{#if runtime.diagnostics.length > 0}
|
||||||
|
<ul class="settings-diagnostics-list">
|
||||||
|
{#each runtime.diagnostics as diagnostic}
|
||||||
|
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
|
||||||
|
<strong>{diagnostic.code}</strong>
|
||||||
|
<span>{diagnostic.message}</span>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
|
||||||
|
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
|
||||||
|
<h2 id="runtime-trust-heading">Workspace trust</h2>
|
||||||
|
|
||||||
|
{#if trust.status !== 'unconfigured'}
|
||||||
|
<div class="runtime-public-key-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="secondary"
|
||||||
|
disabled={busyAction !== null}
|
||||||
|
onclick={togglePublicKeyReveal}
|
||||||
|
>
|
||||||
|
{busyAction === 'reveal' ? 'Loading…' : showPublicKey ? 'Hide public key' : 'Reveal public key'}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="secondary" disabled={busyAction !== null} onclick={copyPublicKey}>
|
||||||
|
{busyAction === 'copy' ? 'Copying…' : 'Copy public key'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{#if showPublicKey && revealedPublicKey}
|
||||||
|
<pre class="runtime-public-key"><code>{revealedPublicKey}</code></pre>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
||||||
|
<label for="runtime-public-key-input">Runtime public key</label>
|
||||||
|
<textarea
|
||||||
|
id="runtime-public-key-input"
|
||||||
|
bind:value={publicKey}
|
||||||
|
rows="5"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
aria-describedby={fieldError ? 'runtime-public-key-error' : undefined}
|
||||||
|
aria-invalid={fieldError ? 'true' : undefined}
|
||||||
|
placeholder="yoi-ed25519-pub:v1:…"
|
||||||
|
></textarea>
|
||||||
|
|
||||||
|
<dl class="runtime-trust-comparison">
|
||||||
|
<div>
|
||||||
|
<dt>Current fingerprint</dt>
|
||||||
|
<dd><code>{trust.fingerprint ?? 'Not configured'}</code></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Replacement fingerprint</dt>
|
||||||
|
<dd><code>{replacementFingerprint ?? 'Enter a valid public key'}</code></dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
{#if replacementFingerprintError}
|
||||||
|
<p class="field-error">{replacementFingerprintError}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if currentAction !== 'create'}
|
||||||
|
<label for="runtime-fingerprint-confirmation">Confirm current fingerprint</label>
|
||||||
|
<input
|
||||||
|
id="runtime-fingerprint-confirmation"
|
||||||
|
bind:value={fingerprintConfirmation}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder={trust.fingerprint ?? ''}
|
||||||
|
/>
|
||||||
|
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly.</small>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if fieldError}
|
||||||
|
<p id="runtime-public-key-error" class="field-error">{fieldError}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="settings-action-row">
|
||||||
|
<button type="submit" disabled={busyAction !== null}>
|
||||||
|
{busyAction === 'save' ? 'Saving…' : actionLabel(currentAction)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="runtime-revoke-row">
|
||||||
|
<div>
|
||||||
|
<strong>Revoke Workspace trust</strong>
|
||||||
|
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
|
||||||
|
<label>
|
||||||
|
Confirm current fingerprint
|
||||||
|
<input
|
||||||
|
bind:value={revokeFingerprintConfirmation}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
disabled={trust.status !== 'active' || busyAction !== null}
|
||||||
|
/>
|
||||||
|
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly before revocation.</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="danger"
|
||||||
|
disabled={
|
||||||
|
busyAction !== null ||
|
||||||
|
trust.status !== 'active' ||
|
||||||
|
revokeFingerprintConfirmation.trim() !== trust.fingerprint
|
||||||
|
}
|
||||||
|
onclick={revokeTrust}
|
||||||
|
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if requestError}
|
||||||
|
<p class="section-state error" role="alert">{requestError}</p>
|
||||||
|
{/if}
|
||||||
|
{#if successMessage}
|
||||||
|
<p class="section-state success" role="status">{successMessage}</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<section class="runtime-detail-section" aria-labelledby="runtime-audit-heading">
|
||||||
|
<h2 id="runtime-audit-heading">Recent trust audit</h2>
|
||||||
|
{#if detail.recent_audit.length === 0}
|
||||||
|
<p class="section-state">No trust changes are recorded.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="runtime-audit-table-wrap">
|
||||||
|
<table class="runtime-audit-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Action</th><th>Revision</th><th>Fingerprint</th><th>Actor</th><th>Time</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each detail.recent_audit as entry}
|
||||||
|
<tr>
|
||||||
|
<td>{entry.action}</td>
|
||||||
|
<td>{entry.revision.toString()}</td>
|
||||||
|
<td><code>{entry.new_fingerprint ?? entry.old_fingerprint ?? '—'}</code></td>
|
||||||
|
<td><code>{entry.actor_account_id}</code></td>
|
||||||
|
<td>{formatTimestamp(entry.at)}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
|
import { parseWorkspaceRuntimeDetail } from "$lib/workspace/api/runtime-management";
|
||||||
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
|
const detail = await loadJson(
|
||||||
|
fetch,
|
||||||
|
workspaceApiPath(
|
||||||
|
params.workspaceId,
|
||||||
|
`/runtimes/${encodeURIComponent(params.runtimeId)}`,
|
||||||
|
),
|
||||||
|
undefined,
|
||||||
|
(value) => {
|
||||||
|
const response = parseWorkspaceRuntimeDetail(value);
|
||||||
|
if (
|
||||||
|
response.workspace_id !== params.workspaceId ||
|
||||||
|
response.runtime.runtime_id !== params.runtimeId
|
||||||
|
) {
|
||||||
|
throw new Error("Runtime detail did not match the route");
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
workspaceId: params.workspaceId,
|
||||||
|
runtimeId: params.runtimeId,
|
||||||
|
runtimeDetail: detail.data,
|
||||||
|
runtimeDetailError: detail.error,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
readTextFile(path: URL): Promise<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Runtime Settings routes validate unknown JSON through the shared Runtime parser", async () => {
|
||||||
|
const [listLoader, detailLoader] = await Promise.all([
|
||||||
|
Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/routes/w/[workspaceId]/settings/runtimes/+page.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
listLoader.includes("parseWorkspaceRuntimeList(value)"),
|
||||||
|
"Runtime list loader should validate unknown JSON",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
detailLoader.includes("parseWorkspaceRuntimeDetail(value)"),
|
||||||
|
"Runtime detail loader should validate unknown JSON",
|
||||||
|
);
|
||||||
|
for (const source of [listLoader, detailLoader]) {
|
||||||
|
assert(
|
||||||
|
!source.includes("loadJson<"),
|
||||||
|
"Runtime loaders must not cast response JSON to a handwritten DTO",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => {
|
||||||
|
const page = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/routes/w/[workspaceId]/settings/runtimes/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
page.includes(
|
||||||
|
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}",
|
||||||
|
),
|
||||||
|
"Runtime name should link to canonical detail",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
page.includes("testRuntime(runtime)"),
|
||||||
|
"connection Test should remain available",
|
||||||
|
);
|
||||||
|
assert(page.includes("Add Runtime"), "Add Runtime should remain available");
|
||||||
|
assert(
|
||||||
|
page.includes("data.workspace.permissions.manage_runtimes"),
|
||||||
|
"Add Runtime should be hidden from non-owners",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!page.includes("deleteRuntime"),
|
||||||
|
"inline Runtime delete logic must be removed",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
!page.includes(">Delete</button>"),
|
||||||
|
"inline Runtime delete control must be removed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", async () => {
|
||||||
|
const page = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
|
||||||
|
const reveal = page.indexOf("Reveal public key");
|
||||||
|
const mutation = page.indexOf('id="runtime-public-key-input"');
|
||||||
|
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
|
||||||
|
assert(
|
||||||
|
page.includes("Current fingerprint"),
|
||||||
|
"current fingerprint must be explicit",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
page.includes("Replacement fingerprint"),
|
||||||
|
"replacement fingerprint must be previewed before confirmation",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
page.includes("!runtime.management.built_in"),
|
||||||
|
"Runtime trust controls should be hidden for the built-in Runtime",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
ownerGate < reveal && ownerGate < mutation,
|
||||||
|
"owner gate should wrap key controls",
|
||||||
|
);
|
||||||
|
|
||||||
|
for (
|
||||||
|
const token of [
|
||||||
|
"Create Workspace trust",
|
||||||
|
"Replace trusted key",
|
||||||
|
"Reactivate with this key",
|
||||||
|
"Confirm current fingerprint",
|
||||||
|
"Revoke Workspace trust",
|
||||||
|
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
||||||
|
"RuntimeTrustConflictError",
|
||||||
|
"RuntimeTrustRouteFence",
|
||||||
|
"routeFence.enter(data.runtimeId)",
|
||||||
|
"showPublicKey = false",
|
||||||
|
"revealedPublicKey = null",
|
||||||
|
"publicKey = ''",
|
||||||
|
"fingerprintConfirmation = ''",
|
||||||
|
"revokeFingerprintConfirmation = ''",
|
||||||
|
"requestError = null",
|
||||||
|
"successMessage = null",
|
||||||
|
"isCurrentRoute(operation)",
|
||||||
|
"revealRuntimeTrustKey",
|
||||||
|
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
|
||||||
|
"await reloadAuthority()",
|
||||||
|
"busyAction !== null",
|
||||||
|
"Workdirs",
|
||||||
|
"Recent trust audit",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(page.includes(token), `Runtime detail should include ${token}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime detail uses flat sections instead of nested cards", async () => {
|
||||||
|
const [page, css] = await Promise.all([
|
||||||
|
Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte",
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Deno.readTextFile(
|
||||||
|
new URL("../src/lib/workspace/styles/settings.css", import.meta.url),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert(
|
||||||
|
!page.includes('class="card"') && !page.includes("settings-card"),
|
||||||
|
"Runtime detail should not add card nesting",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
css.includes(".runtime-detail-section") &&
|
||||||
|
css.includes("border-top: 1px solid var(--line)"),
|
||||||
|
"Runtime detail hierarchy should use flat section separators",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseRuntimeTrustConflict,
|
||||||
|
parseRuntimeTrustKeyRevealResponse,
|
||||||
|
parseWorkspaceRuntimeDetail,
|
||||||
|
parseWorkspaceRuntimeList,
|
||||||
|
previewRuntimePublicKeyFingerprint,
|
||||||
|
putRuntimeTrustKey,
|
||||||
|
revokeRuntimeTrustKey,
|
||||||
|
RuntimeTrustConflictError,
|
||||||
|
RuntimeTrustRouteFence,
|
||||||
|
} from "../src/lib/workspace/api/runtime-management.ts";
|
||||||
|
|
||||||
|
function assert(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertThrows(operation: () => unknown, expected: string): void {
|
||||||
|
try {
|
||||||
|
operation();
|
||||||
|
} catch (error) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
if (message.includes(expected)) return;
|
||||||
|
throw new Error(
|
||||||
|
`expected error containing ${expected}, received ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error("expected operation to throw");
|
||||||
|
}
|
||||||
|
|
||||||
|
function runtime() {
|
||||||
|
return {
|
||||||
|
management: {
|
||||||
|
built_in: false,
|
||||||
|
config_managed: true,
|
||||||
|
removable: false,
|
||||||
|
endpoint_configured: true,
|
||||||
|
token_ref_configured: false,
|
||||||
|
},
|
||||||
|
runtime_id: "arcadia",
|
||||||
|
label: "Arcadia",
|
||||||
|
kind: "remote",
|
||||||
|
status: "started",
|
||||||
|
source: {
|
||||||
|
kind: "remote_http",
|
||||||
|
status: "active",
|
||||||
|
identity_authority: "server_runtime_configuration",
|
||||||
|
note: "Configured by Server authority",
|
||||||
|
},
|
||||||
|
host_ids: ["host-a"],
|
||||||
|
worker_creation_available: true,
|
||||||
|
os: "linux",
|
||||||
|
arch: "x86_64",
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function detail() {
|
||||||
|
return {
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
runtime: runtime(),
|
||||||
|
endpoint: "https://runtime.example.test",
|
||||||
|
trust_key: {
|
||||||
|
status: "active",
|
||||||
|
fingerprint: "SHA256:current",
|
||||||
|
revision: 3,
|
||||||
|
created_at: "2026-09-01T12:00:00Z",
|
||||||
|
updated_at: "2026-09-01T13:00:00Z",
|
||||||
|
revoked_at: null,
|
||||||
|
},
|
||||||
|
recent_audit: [{
|
||||||
|
action: "created",
|
||||||
|
actor_account_id: "account-a",
|
||||||
|
old_fingerprint: null,
|
||||||
|
new_fingerprint: "SHA256:current",
|
||||||
|
revision: 3,
|
||||||
|
at: "2026-09-01T13:00:00Z",
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes", () => {
|
||||||
|
const list = parseWorkspaceRuntimeList({
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
limit: 200,
|
||||||
|
items: [runtime()],
|
||||||
|
source: "workspace-control-plane",
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
list.items[0]?.runtime_id === "arcadia",
|
||||||
|
"Runtime ID was not preserved",
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = parseWorkspaceRuntimeDetail(detail());
|
||||||
|
assert(
|
||||||
|
parsed.trust_key.revision === 3,
|
||||||
|
"revision was not preserved as a safe integer",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
parsed.recent_audit[0]?.revision === 3,
|
||||||
|
"audit revision was not normalized",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }),
|
||||||
|
"head_tree is not part",
|
||||||
|
);
|
||||||
|
|
||||||
|
const futureSource = structuredClone(detail());
|
||||||
|
futureSource.runtime.source.kind = "future_transport";
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkspaceRuntimeDetail(futureSource),
|
||||||
|
"contains an unknown enum value",
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseRuntimeTrustConflict({
|
||||||
|
error: "future_conflict",
|
||||||
|
message: "conflict",
|
||||||
|
current_revision: 4,
|
||||||
|
current_fingerprint: "SHA256:new",
|
||||||
|
}),
|
||||||
|
"contains an unknown enum value",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime validators reject unsafe revisions and bounded collection overflow", () => {
|
||||||
|
const unsafeRevision = structuredClone(detail());
|
||||||
|
unsafeRevision.trust_key.revision = Number.MAX_SAFE_INTEGER + 1;
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkspaceRuntimeDetail(unsafeRevision),
|
||||||
|
"must be a safe integer",
|
||||||
|
);
|
||||||
|
|
||||||
|
const tooMuchAudit = structuredClone(detail());
|
||||||
|
tooMuchAudit.recent_audit = Array.from(
|
||||||
|
{ length: 21 },
|
||||||
|
() => structuredClone(detail().recent_audit[0]),
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkspaceRuntimeDetail(tooMuchAudit),
|
||||||
|
"must contain at most 20 items",
|
||||||
|
);
|
||||||
|
|
||||||
|
const tooManyItems = Array.from({ length: 201 }, () => runtime());
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseWorkspaceRuntimeList({
|
||||||
|
workspace_id: "workspace-a",
|
||||||
|
limit: 200,
|
||||||
|
items: tooManyItems,
|
||||||
|
source: "workspace-control-plane",
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
"must contain at most 200 items",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", () => {
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseRuntimeTrustKeyRevealResponse({
|
||||||
|
public_key: "x".repeat(16 * 1024 + 1),
|
||||||
|
}),
|
||||||
|
"must be at most 16384 UTF-8 bytes",
|
||||||
|
);
|
||||||
|
|
||||||
|
const activeWithoutFingerprint = structuredClone(detail()) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
|
(activeWithoutFingerprint.trust_key as Record<string, unknown>).fingerprint =
|
||||||
|
null;
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkspaceRuntimeDetail(activeWithoutFingerprint),
|
||||||
|
"must include fingerprint",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("mismatched revoke fingerprint never sends a request", async () => {
|
||||||
|
let requests = 0;
|
||||||
|
const fetchImpl: typeof fetch = () => {
|
||||||
|
requests += 1;
|
||||||
|
return Promise.reject(new Error("request must not be sent"));
|
||||||
|
};
|
||||||
|
let rejected = false;
|
||||||
|
try {
|
||||||
|
await revokeRuntimeTrustKey(
|
||||||
|
"workspace-a",
|
||||||
|
"runtime-a",
|
||||||
|
{ expected_revision: 3 },
|
||||||
|
"sha256:current",
|
||||||
|
"sha256:different",
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
rejected = error instanceof Error &&
|
||||||
|
error.message.includes("current fingerprint exactly");
|
||||||
|
}
|
||||||
|
assert(rejected, "mismatched fingerprint should be rejected locally");
|
||||||
|
assert(requests === 0, "mismatched fingerprint sent a revoke request");
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {
|
||||||
|
const fence = new RuntimeTrustRouteFence();
|
||||||
|
fence.enter("runtime-a");
|
||||||
|
const operation = fence.capture("runtime-a");
|
||||||
|
let renderedKey: string | null = null;
|
||||||
|
let resolveReveal!: (key: string) => void;
|
||||||
|
const delayedReveal = new Promise<string>((resolve) => {
|
||||||
|
resolveReveal = resolve;
|
||||||
|
}).then((key) => {
|
||||||
|
if (fence.isCurrent(operation, "runtime-b")) renderedKey = key;
|
||||||
|
});
|
||||||
|
|
||||||
|
fence.enter("runtime-b");
|
||||||
|
resolveReveal("runtime-a-public-key");
|
||||||
|
await delayedReveal;
|
||||||
|
assert(
|
||||||
|
renderedKey === null,
|
||||||
|
"Runtime A key rendered after navigating to Runtime B",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime public key preview matches the Server fingerprint contract", async () => {
|
||||||
|
const fingerprint = await previewRuntimePublicKeyFingerprint(
|
||||||
|
"yoi-ed25519-pub:v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
fingerprint ===
|
||||||
|
"sha256:66687aadf862bd776c8fc18b8e9f8e20089714856ee233b3902a591d0d5f2925",
|
||||||
|
"fingerprint preview drifted from the Server SHA-256 contract",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("typed trust conflict is validated and preserves authoritative revision", async () => {
|
||||||
|
let sentBody: unknown = null;
|
||||||
|
const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
sentBody = JSON.parse(String(init?.body)) as unknown;
|
||||||
|
return Promise.resolve(
|
||||||
|
new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
error: "stale_revision",
|
||||||
|
message: "Runtime trust changed",
|
||||||
|
current_revision: 4,
|
||||||
|
current_fingerprint: "SHA256:new",
|
||||||
|
}),
|
||||||
|
{ status: 409, headers: { "content-type": "application/json" } },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}) as typeof fetch;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await putRuntimeTrustKey(
|
||||||
|
"workspace-a",
|
||||||
|
"arcadia",
|
||||||
|
{ public_key: "ssh-ed25519 AAAA-new", expected_revision: 3 },
|
||||||
|
fetchImpl,
|
||||||
|
);
|
||||||
|
throw new Error("expected mutation to reject");
|
||||||
|
} catch (error) {
|
||||||
|
assert(
|
||||||
|
error instanceof RuntimeTrustConflictError,
|
||||||
|
"expected typed conflict",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
error.conflict.current_revision === 4,
|
||||||
|
"authoritative revision was lost",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert(
|
||||||
|
JSON.stringify(sentBody) ===
|
||||||
|
JSON.stringify({
|
||||||
|
public_key: "ssh-ed25519 AAAA-new",
|
||||||
|
expected_revision: 3,
|
||||||
|
}),
|
||||||
|
"request should serialize the generated bigint revision as a safe JSON integer",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user