mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
fix(runtime): recover owner startup and preserve reviewed plugin updates
Concurrent fresh starts and verified-dead owner reentries converge on the incumbent Runtime owner and retain its selected configuration, store and frontend through cancelled bootstrap and retirement work. Only typed pre-attach receipt retirement is re-observed; unsafe permissions, ownership, hardlinks, replacement publications and uncertain post-write effects remain refusals. macOS/Linux no-replace receipt publication keeps one filesystem link. Local-path plugin updates verify the whole installed bundle and retain a private unique recovery backup during replacement. Internal staging/backup paths stay out of discovery. Runtime auto-allow paths use the shared approval disposition, preserving forced human approval and Never posture. Verification: repaired production source32af4d9 passed fmt, CLI/TUI Clippy, CLI build, 77/77 focused TUI/plugin/startup/approval checks, and18/18 private file/xAI credential checks. Final85da82f changes only a cfg(test) import and embedded release prose: fmt and app-server all-target/all-feature Clippy passed;22/22 daemon/client/server checks passed. Feature registry:6/6 passed. All30 newly added regression functions have actual PASS receipts. Actual matching immutable dcb50251908d CLI:80/80 owner startup/reentry checks plus9/9 native aa70de8b97a5 app recovery checks; all25 task PIDs absent; zero provider/inference requests. Independent whole-fingerprint reconstruction proves final production Rust equivalent; final embedded notes differ. Earlier6ac94bff8904 plugin update/repair/rollback qualification:24/24 checks, two bounded real DeepSeek turns with one exact canary MCP invocation each. That earlier provider proof is not relabeled as matching dcb binary evidence. Original startup and peer-server test compile failures are retained. Windows, hosted/release/customer acceptance, full plugin serialization and native positive inference remain required. Local source commit only; release owner controls Engine push and promotion. Qualified receipt SHA256 939d832b6f0ef8b0dc5451ef115678e97da0f1fcc2a372019d057a6b185cff46; immutable recovery receipts: 80checks650654aa6a50b1c5f278eda0ee21a979931ca20d382e83bb607b43845953643a 9checks2554c10eb0c21bb2027a18e2a9d465eaf862bf2c6e0d2f4ffc678a5c736813ee.
This commit is contained in:
1 parent
bd0c325670
commit
a8cdccc1d6
23 files changed
+2739
-307
No files matched your search
@@ -34,6 +34,237 @@ mod platform {
|
||||
frontend: crate::daemon_socket::AttachFrontend,
|
||||
}
|
||||
|
||||
/// Host startup is the only caller allowed to acquire the Runtime lease.
|
||||
/// Unavailable is pre-write transport evidence, never ownership or death.
|
||||
pub enum HostOwnerProbe {
|
||||
Absent,
|
||||
Attached(Box<OwnerClient>),
|
||||
Unavailable(Arc<UnavailablePublication>),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[error("selected owner publication changed before attach; re-observe")]
|
||||
pub struct PublicationChangedBeforeAttach;
|
||||
|
||||
fn owner_receipt_error(error: anyhow::Error) -> anyhow::Error {
|
||||
#[cfg(unix)]
|
||||
if error.is::<codewhale_config::private_directory::UnlinkedPrivateFile>() {
|
||||
return PublicationChangedBeforeAttach.into();
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
pub(crate) struct OwnerPublication {
|
||||
pub(crate) parent: Arc<PrivateDirectory>,
|
||||
pub(crate) receipt_name: String,
|
||||
#[cfg(unix)]
|
||||
pub(crate) socket_path: PathBuf,
|
||||
pub(crate) bytes: Vec<u8>,
|
||||
pub(crate) receipt: RuntimeOwnerReceipt,
|
||||
pub(crate) file: std::fs::File,
|
||||
#[cfg(unix)]
|
||||
pub(crate) socket_identity:
|
||||
Option<codewhale_config::private_directory::PrivateSocketIdentity>,
|
||||
}
|
||||
impl OwnerPublication {
|
||||
pub(crate) fn receipt_is_current(&self) -> Result<bool> {
|
||||
if !self.parent.is_at_selected_path()? {
|
||||
return Ok(false);
|
||||
}
|
||||
let current = match self
|
||||
.parent
|
||||
.read_private_receipt(&self.receipt_name, 16384)
|
||||
.map_err(owner_receipt_error)
|
||||
{
|
||||
Err(error) if error.is::<PublicationChangedBeforeAttach>() => return Ok(false),
|
||||
result => result?,
|
||||
};
|
||||
let Some((bytes, file)) = current else {
|
||||
return Ok(false);
|
||||
};
|
||||
Ok(bytes == self.bytes && PrivateDirectory::same_file_identity(&self.file, &file)?)
|
||||
}
|
||||
pub(crate) fn is_current(&self) -> Result<bool> {
|
||||
if !self.receipt_is_current()? {
|
||||
return Ok(false);
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let name = self
|
||||
.socket_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("invalid owner endpoint basename")?;
|
||||
if self.parent.socket_identity(name)? != self.socket_identity {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Opaque retained publication. No public constructor or path-based cleanup.
|
||||
pub struct UnavailablePublication {
|
||||
pub(crate) publication: Arc<OwnerPublication>,
|
||||
cause: std::io::Error,
|
||||
}
|
||||
impl UnavailablePublication {
|
||||
pub fn receipt(&self) -> &RuntimeOwnerReceipt {
|
||||
&self.publication.receipt
|
||||
}
|
||||
fn strict_error(&self) -> anyhow::Error {
|
||||
let cause = match self.cause.raw_os_error() {
|
||||
Some(code) => std::io::Error::from_raw_os_error(code),
|
||||
None => std::io::Error::new(self.cause.kind(), self.cause.to_string()),
|
||||
};
|
||||
anyhow::Error::new(cause)
|
||||
.context("selected owner publication unavailable; canonical host recovery required")
|
||||
}
|
||||
#[cfg(unix)]
|
||||
fn require_dead_process(&self) -> Result<()> {
|
||||
use codewhale_config::private_directory::{UnixProcessStatus, unix_process_status};
|
||||
let receipt = self.receipt();
|
||||
anyhow::ensure!(
|
||||
receipt.principal == PrivateDirectory::current_user_id().to_string()
|
||||
&& !receipt.process_start.is_empty(),
|
||||
"selected owner process identity is invalid"
|
||||
);
|
||||
match unix_process_status(receipt.pid)? {
|
||||
UnixProcessStatus::Absent => Ok(()),
|
||||
UnixProcessStatus::Present { start } if start == receipt.process_start => {
|
||||
bail!("selected owner process is still present; refusing recovery")
|
||||
}
|
||||
UnixProcessStatus::Present { .. } => {
|
||||
bail!("selected owner PID was reused; refusing recovery")
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Caller retains the real Runtime lease; this checks evidence only.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn revalidate_receipt(&self) -> Result<bool> {
|
||||
if !self.publication.receipt_is_current()? {
|
||||
return Ok(false);
|
||||
}
|
||||
self.require_dead_process()?;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn revalidate_all(&self) -> Result<bool> {
|
||||
if !self.publication.is_current()? {
|
||||
return Ok(false);
|
||||
}
|
||||
self.require_dead_process()?;
|
||||
Ok(true)
|
||||
}
|
||||
#[cfg(unix)]
|
||||
pub async fn revalidate(self: &Arc<Self>) -> Result<bool> {
|
||||
let held = self.clone();
|
||||
owner_work(move || held.revalidate_all()).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe_publication(
|
||||
config_path: Option<PathBuf>,
|
||||
selected: Option<PathBuf>,
|
||||
) -> Result<Option<Arc<OwnerPublication>>> {
|
||||
let socket_path = owner_work(move || match selected {
|
||||
Some(path) => Ok(path),
|
||||
None => default_socket_path().map_err(Into::into),
|
||||
})
|
||||
.await?;
|
||||
#[cfg(unix)]
|
||||
let directory = socket_path
|
||||
.parent()
|
||||
.context("owner endpoint has no private parent")?
|
||||
.to_path_buf();
|
||||
#[cfg(windows)]
|
||||
let directory = owner_work(crate::daemon_windows::owner_directory).await?;
|
||||
#[cfg(unix)]
|
||||
let name = format!(
|
||||
"{}.owner.json",
|
||||
socket_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("invalid owner endpoint basename")?
|
||||
);
|
||||
#[cfg(windows)]
|
||||
let name = "daemon.owner.json".to_string();
|
||||
owner_work(move || {
|
||||
let parent = match PrivateDirectory::inspect(&directory) {
|
||||
Ok(parent) => Arc::new(parent),
|
||||
Err(error)
|
||||
if error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let Some((bytes, file)) = parent
|
||||
.read_private_receipt(&name, 16384)
|
||||
.map_err(owner_receipt_error)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let receipt: RuntimeOwnerReceipt = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
receipt.version == 1
|
||||
&& receipt.pid > 0
|
||||
&& receipt.socket_path == socket_path
|
||||
&& !receipt.lease_generation.is_empty(),
|
||||
"selected owner receipt is invalid"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
receipt.config_path == config_path,
|
||||
"selected owner config does not match; refusing another store"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
let socket_identity = parent.socket_identity(
|
||||
socket_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("invalid owner endpoint basename")?,
|
||||
)?;
|
||||
Ok(Some(Arc::new(OwnerPublication {
|
||||
parent,
|
||||
receipt_name: name,
|
||||
bytes,
|
||||
receipt,
|
||||
file,
|
||||
#[cfg(unix)]
|
||||
socket_path,
|
||||
#[cfg(unix)]
|
||||
socket_identity,
|
||||
})))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Read-only absence recheck after the actual Runtime lease was acquired.
|
||||
pub async fn publication_is_absent(
|
||||
config_path: Option<PathBuf>,
|
||||
selected: Option<PathBuf>,
|
||||
) -> Result<bool> {
|
||||
Ok(observe_publication(config_path, selected).await?.is_none())
|
||||
}
|
||||
|
||||
pub async fn probe_owner_for_host_startup(
|
||||
config_path: Option<PathBuf>,
|
||||
selected: Option<PathBuf>,
|
||||
) -> Result<HostOwnerProbe> {
|
||||
probe_frontend(
|
||||
config_path,
|
||||
selected,
|
||||
crate::daemon_socket::AttachFrontend::Control,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn connect(
|
||||
config_path: Option<PathBuf>,
|
||||
selected: Option<PathBuf>,
|
||||
@@ -141,68 +372,65 @@ mod platform {
|
||||
scope: Option<crate::RuntimeFrontendScope>,
|
||||
acp_model: Option<String>,
|
||||
) -> Result<Option<OwnerClient>> {
|
||||
let socket_path = owner_work(move || match selected {
|
||||
Some(path) => Ok(path),
|
||||
None => default_socket_path().map_err(Into::into),
|
||||
})
|
||||
.await?;
|
||||
#[cfg(unix)]
|
||||
let directory = socket_path
|
||||
.parent()
|
||||
.context("owner endpoint has no private parent")?
|
||||
.to_path_buf();
|
||||
#[cfg(windows)]
|
||||
let directory = owner_work(crate::daemon_windows::owner_directory).await?;
|
||||
#[cfg(unix)]
|
||||
let name = format!(
|
||||
"{}.owner.json",
|
||||
socket_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.context("invalid owner endpoint basename")?
|
||||
);
|
||||
#[cfg(windows)]
|
||||
let name = "daemon.owner.json".to_string();
|
||||
let receipt_name = name.clone();
|
||||
let found = owner_work(move || {
|
||||
let parent = match PrivateDirectory::inspect(&directory) {
|
||||
Ok(parent) => Arc::new(parent),
|
||||
Err(error)
|
||||
if error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|error| error.kind() == std::io::ErrorKind::NotFound) =>
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let Some((bytes, file)) = parent.read_private_receipt(&name, 16384)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let receipt: RuntimeOwnerReceipt = serde_json::from_slice(&bytes)?;
|
||||
Ok(Some((parent, receipt, file)))
|
||||
})
|
||||
.await?;
|
||||
let Some((parent, receipt, file)) = found else {
|
||||
return Ok(None);
|
||||
match probe_frontend(
|
||||
config_path,
|
||||
selected,
|
||||
frontend,
|
||||
listener,
|
||||
expected_owner,
|
||||
scope,
|
||||
acp_model,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
HostOwnerProbe::Absent => Ok(None),
|
||||
HostOwnerProbe::Attached(client) => Ok(Some(*client)),
|
||||
HostOwnerProbe::Unavailable(publication) => Err(publication.strict_error()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn probe_frontend(
|
||||
config_path: Option<PathBuf>,
|
||||
selected: Option<PathBuf>,
|
||||
frontend: crate::daemon_socket::AttachFrontend,
|
||||
listener: Option<crate::RuntimeListenerSelection>,
|
||||
expected_owner: Option<RuntimeOwnerReceipt>,
|
||||
scope: Option<crate::RuntimeFrontendScope>,
|
||||
acp_model: Option<String>,
|
||||
) -> Result<HostOwnerProbe> {
|
||||
let Some(publication) = observe_publication(config_path, selected).await? else {
|
||||
return Ok(HostOwnerProbe::Absent);
|
||||
};
|
||||
anyhow::ensure!(
|
||||
receipt.version == 1
|
||||
&& receipt.pid > 0
|
||||
&& receipt.socket_path == socket_path
|
||||
&& !receipt.lease_generation.is_empty(),
|
||||
"selected owner receipt is invalid"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
receipt.config_path == config_path,
|
||||
"selected owner config does not match; refusing another store"
|
||||
);
|
||||
#[cfg(unix)]
|
||||
let socket_path = publication.socket_path.clone();
|
||||
let receipt = publication.receipt.clone();
|
||||
#[cfg(unix)]
|
||||
let (stream, peer) = {
|
||||
let stream =
|
||||
tokio::time::timeout(Duration::from_secs(5), UnixStream::connect(&socket_path))
|
||||
.await
|
||||
.context("owner connect deadline expired")??;
|
||||
let stream = match tokio::time::timeout(
|
||||
Duration::from_secs(5),
|
||||
UnixStream::connect(&socket_path),
|
||||
)
|
||||
.await
|
||||
.context("owner connect deadline expired")?
|
||||
{
|
||||
Ok(stream) => stream,
|
||||
Err(cause) => {
|
||||
let held = publication.clone();
|
||||
anyhow::ensure!(
|
||||
owner_work(move || held.is_current()).await?,
|
||||
PublicationChangedBeforeAttach
|
||||
);
|
||||
if cause.kind() == std::io::ErrorKind::ConnectionRefused
|
||||
|| (cause.kind() == std::io::ErrorKind::NotFound
|
||||
&& publication.socket_identity.is_none())
|
||||
{
|
||||
return Ok(HostOwnerProbe::Unavailable(Arc::new(
|
||||
UnavailablePublication { publication, cause },
|
||||
)));
|
||||
}
|
||||
return Err(cause.into());
|
||||
}
|
||||
};
|
||||
let credential = stream
|
||||
.peer_cred()
|
||||
.context("owner peer credentials unavailable")?;
|
||||
@@ -210,12 +438,17 @@ mod platform {
|
||||
.pid()
|
||||
.and_then(|pid| u32::try_from(pid).ok())
|
||||
.context("owner peer PID unavailable")?;
|
||||
anyhow::ensure!(
|
||||
credential.uid() == PrivateDirectory::current_user_id()
|
||||
&& receipt.principal == credential.uid().to_string()
|
||||
&& pid == receipt.pid,
|
||||
"connected process is not the selected Runtime owner"
|
||||
);
|
||||
if credential.uid() != PrivateDirectory::current_user_id()
|
||||
|| receipt.principal != credential.uid().to_string()
|
||||
|| pid != receipt.pid
|
||||
{
|
||||
let held = publication.clone();
|
||||
anyhow::ensure!(
|
||||
owner_work(move || held.is_current()).await?,
|
||||
PublicationChangedBeforeAttach
|
||||
);
|
||||
bail!("connected process is not the selected Runtime owner");
|
||||
}
|
||||
(
|
||||
stream,
|
||||
Arc::new(AuthorizedPeer {
|
||||
@@ -234,22 +467,13 @@ mod platform {
|
||||
};
|
||||
(stream, Arc::new(peer))
|
||||
};
|
||||
let held_parent = parent.clone();
|
||||
let held_receipt = receipt.clone();
|
||||
let held_publication = publication.clone();
|
||||
let check = peer.clone();
|
||||
owner_work(move || {
|
||||
check.check()?;
|
||||
anyhow::ensure!(
|
||||
held_parent.is_at_selected_path()?,
|
||||
"selected owner parent changed"
|
||||
);
|
||||
let (bytes, current) = held_parent
|
||||
.read_private_receipt(&receipt_name, 16384)?
|
||||
.context("selected owner receipt withdrawn")?;
|
||||
anyhow::ensure!(
|
||||
serde_json::from_slice::<RuntimeOwnerReceipt>(&bytes)? == held_receipt
|
||||
&& PrivateDirectory::same_file_identity(&file, ¤t)?,
|
||||
"selected owner receipt changed before attach"
|
||||
held_publication.is_current()?,
|
||||
PublicationChangedBeforeAttach
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
@@ -297,14 +521,14 @@ mod platform {
|
||||
.filter(|value| !value.is_null())
|
||||
.map(|value| serde_json::from_value(value.clone()))
|
||||
.transpose()?;
|
||||
Ok(Some(OwnerClient {
|
||||
Ok(HostOwnerProbe::Attached(Box::new(OwnerClient {
|
||||
read: replies,
|
||||
write,
|
||||
peer,
|
||||
receipt,
|
||||
routing,
|
||||
frontend,
|
||||
}))
|
||||
})))
|
||||
}
|
||||
|
||||
impl OwnerClient {
|
||||
@@ -394,13 +618,34 @@ mod platform {
|
||||
.forward(tokio::io::stdin(), tokio::io::stdout())
|
||||
.await
|
||||
}
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn owner_receipt_reobserves_only_typed_unlinked_file_refusals() {
|
||||
let retired =
|
||||
anyhow::Error::new(codewhale_config::private_directory::UnlinkedPrivateFile)
|
||||
.context("bounded owner receipt read");
|
||||
assert!(owner_receipt_error(retired).is::<PublicationChangedBeforeAttach>());
|
||||
for refused in [
|
||||
anyhow::anyhow!("private file was unlinked before validation"),
|
||||
anyhow::anyhow!("xAI OAuth file must not have multiple filesystem links"),
|
||||
std::io::Error::from(std::io::ErrorKind::PermissionDenied).into(),
|
||||
anyhow::anyhow!("owner attach write deadline expired; outcome uncertain"),
|
||||
] {
|
||||
assert!(!owner_receipt_error(refused).is::<PublicationChangedBeforeAttach>());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
pub use platform::{
|
||||
OwnerClient, connect, connect_acp_if_published, connect_if_published,
|
||||
connect_listener_if_published, connect_scoped_control_if_published,
|
||||
connect_selected_acp_if_published, forward_stdio,
|
||||
HostOwnerProbe, OwnerClient, PublicationChangedBeforeAttach, UnavailablePublication, connect,
|
||||
connect_acp_if_published, connect_if_published, connect_listener_if_published,
|
||||
connect_scoped_control_if_published, connect_selected_acp_if_published, forward_stdio,
|
||||
probe_owner_for_host_startup, publication_is_absent,
|
||||
};
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
|
||||
@@ -797,12 +797,20 @@ mod platform {
|
||||
/// How long the stale-socket probe waits for a connect to resolve.
|
||||
const PROBE_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
|
||||
#[cfg(test)]
|
||||
type BeforeOwnerRebind = Box<dyn FnOnce(&Path)>;
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static BEFORE_OWNER_REBIND: std::cell::RefCell<Option<BeforeOwnerRebind>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Removes the socket file when the server stops, however it stops.
|
||||
struct SocketFileGuard {
|
||||
parent: Arc<PrivateDirectory>,
|
||||
name: String,
|
||||
identity: PrivateSocketIdentity,
|
||||
receipt: Option<(String, File)>,
|
||||
owner_state: Option<AppState>,
|
||||
retired: bool,
|
||||
}
|
||||
|
||||
@@ -816,7 +824,9 @@ mod platform {
|
||||
let name = self.name.clone();
|
||||
let identity = self.identity;
|
||||
let receipt = self.receipt.take();
|
||||
let owner_state = self.owner_state.take();
|
||||
Some(move || {
|
||||
let _held_owner = owner_state;
|
||||
if let Some((name, file)) = receipt {
|
||||
parent.retire_private_receipt(&name, &file)?;
|
||||
}
|
||||
@@ -835,6 +845,20 @@ mod platform {
|
||||
}
|
||||
}
|
||||
|
||||
// Blocking endpoint mutations retain the actual captured manager through
|
||||
// its existing frontend, even after cancellation of the async caller.
|
||||
async fn socket_owner_work<T, F>(owner_state: Option<AppState>, work: F) -> Result<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce() -> Result<T> + Send + 'static,
|
||||
{
|
||||
super::owner_work(move || {
|
||||
let _held_owner = owner_state;
|
||||
work()
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
impl Drop for SocketFileGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(retire) = self.retirement() else {
|
||||
@@ -939,41 +963,87 @@ mod platform {
|
||||
pub async fn bind_daemon_socket(
|
||||
options: DaemonSocketOptions,
|
||||
) -> Result<DaemonSocket, DaemonSocketError> {
|
||||
bind_with_owner(options, None).await
|
||||
bind_with_owner(options, None, None).await
|
||||
}
|
||||
|
||||
pub(crate) async fn bind_captured_owner(
|
||||
state: AppState,
|
||||
owner: super::RuntimeOwnerReceipt,
|
||||
recovery: Option<Arc<crate::daemon_client::UnavailablePublication>>,
|
||||
) -> Result<DaemonSocket, DaemonSocketError> {
|
||||
let options = DaemonSocketOptions {
|
||||
socket_path: Some(owner.socket_path.clone()),
|
||||
config_path: None,
|
||||
};
|
||||
bind_with_owner(options, Some((state, owner))).await
|
||||
bind_with_owner(options, Some((state, owner)), recovery).await
|
||||
}
|
||||
|
||||
async fn bind_with_owner(
|
||||
options: DaemonSocketOptions,
|
||||
captured: Option<(AppState, super::RuntimeOwnerReceipt)>,
|
||||
recovery: Option<Arc<crate::daemon_client::UnavailablePublication>>,
|
||||
) -> Result<DaemonSocket, DaemonSocketError> {
|
||||
let owner_state = captured.as_ref().map(|(state, _)| state.clone());
|
||||
let path = resolve_socket_path(&SocketPathInputs::from_environment(options.socket_path)?)?;
|
||||
let parent_path = path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
.ok_or(DaemonSocketError::RuntimeDirUnavailable)?
|
||||
.to_path_buf();
|
||||
let parent = Arc::new(
|
||||
super::owner_work(move || PrivateDirectory::admit(&parent_path))
|
||||
let parent = if let Some(previous) = recovery.as_ref() {
|
||||
let owner = &captured
|
||||
.as_ref()
|
||||
.ok_or_else(|| {
|
||||
DaemonSocketError::State(anyhow::anyhow!(
|
||||
"stale publication requires the actual captured Runtime owner"
|
||||
))
|
||||
})?
|
||||
.1;
|
||||
let old = previous.receipt();
|
||||
if old.version != owner.version
|
||||
|| old.data_dir != owner.data_dir
|
||||
|| old.execution_scope != owner.execution_scope
|
||||
|| old.socket_path != path
|
||||
|| old.config_path != owner.config_path
|
||||
|| old.principal != owner.principal
|
||||
{
|
||||
return Err(DaemonSocketError::State(anyhow::anyhow!(
|
||||
"captured stale owner belongs to another selected store/config/principal"
|
||||
)));
|
||||
}
|
||||
if !previous
|
||||
.revalidate()
|
||||
.await
|
||||
.map_err(DaemonSocketError::State)?
|
||||
{
|
||||
return Err(DaemonSocketError::State(anyhow::anyhow!(
|
||||
"captured stale owner changed before bind"
|
||||
)));
|
||||
}
|
||||
previous.publication.parent.clone()
|
||||
} else {
|
||||
Arc::new(
|
||||
socket_owner_work(owner_state.clone(), move || {
|
||||
PrivateDirectory::admit(&parent_path)
|
||||
})
|
||||
.await
|
||||
.map_err(DaemonSocketError::State)?,
|
||||
);
|
||||
)
|
||||
};
|
||||
let name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or(DaemonSocketError::RuntimeDirUnavailable)?
|
||||
.to_string();
|
||||
clear_stale_socket(&path, &parent, &name).await?;
|
||||
clear_stale_socket(
|
||||
&path,
|
||||
&parent,
|
||||
&name,
|
||||
recovery.clone(),
|
||||
captured.is_some() && recovery.is_none(),
|
||||
owner_state.clone(),
|
||||
)
|
||||
.await?;
|
||||
let check_parent = parent.clone();
|
||||
if !super::owner_work(move || check_parent.is_at_selected_path())
|
||||
.await
|
||||
@@ -984,6 +1054,35 @@ mod platform {
|
||||
)));
|
||||
}
|
||||
|
||||
// Withdraw the exact dead publication before a new PID can answer on
|
||||
// its endpoint. Contenders then re-observe absence and the held lease.
|
||||
if let Some(previous) = recovery.as_ref() {
|
||||
let previous = previous.clone();
|
||||
let parent = parent.clone();
|
||||
socket_owner_work(owner_state.clone(), move || {
|
||||
anyhow::ensure!(
|
||||
previous.revalidate_receipt()?,
|
||||
"captured stale owner receipt changed before retirement"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
parent.retire_private_receipt(
|
||||
&previous.publication.receipt_name,
|
||||
&previous.publication.file
|
||||
)?,
|
||||
"captured stale owner receipt changed during retirement"
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(DaemonSocketError::State)?;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
BEFORE_OWNER_REBIND.with(|slot| {
|
||||
if let Some(observe) = slot.borrow_mut().take() {
|
||||
observe(&path);
|
||||
}
|
||||
});
|
||||
let listener = UnixListener::bind(&path).map_err(|source| DaemonSocketError::Io {
|
||||
context: "failed to bind the daemon socket",
|
||||
path: path.clone(),
|
||||
@@ -1002,11 +1101,12 @@ mod platform {
|
||||
name,
|
||||
identity,
|
||||
receipt: None,
|
||||
owner_state: owner_state.clone(),
|
||||
retired: false,
|
||||
};
|
||||
let protect_parent = parent.clone();
|
||||
let protect_name = socket_file.name.clone();
|
||||
super::owner_work(move || {
|
||||
socket_owner_work(owner_state.clone(), move || {
|
||||
anyhow::ensure!(
|
||||
protect_parent.is_at_selected_path()?,
|
||||
"private endpoint parent changed during bind"
|
||||
@@ -1037,28 +1137,17 @@ mod platform {
|
||||
}
|
||||
let parent = parent.clone();
|
||||
let name = receipt_name.clone();
|
||||
let expected = owner.clone();
|
||||
let guard = socket_file;
|
||||
socket_file = super::owner_work(move || {
|
||||
socket_file = socket_owner_work(owner_state, move || {
|
||||
let mut guard = guard;
|
||||
anyhow::ensure!(
|
||||
parent.is_at_selected_path()?,
|
||||
"private owner parent changed before publication"
|
||||
);
|
||||
if let Some((bytes, old)) = parent.read_private_receipt(&name, 16384)? {
|
||||
let previous: super::RuntimeOwnerReceipt = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
previous.version == expected.version
|
||||
&& previous.data_dir == expected.data_dir
|
||||
&& previous.execution_scope == expected.execution_scope
|
||||
&& previous.socket_path == expected.socket_path,
|
||||
"stale owner receipt belongs to a different selected store"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
parent.retire_private_receipt(&name, &old)?,
|
||||
"stale owner receipt changed"
|
||||
);
|
||||
}
|
||||
anyhow::ensure!(
|
||||
parent.read_private_receipt(&name, 16384)?.is_none(),
|
||||
"owner publication appeared during bootstrap; refusing replacement"
|
||||
);
|
||||
parent.write_owned_file(&name, &bytes, false)?;
|
||||
let (_, file) = parent
|
||||
.read_private_receipt(&name, 16384)?
|
||||
@@ -1089,24 +1178,44 @@ mod platform {
|
||||
path: &Path,
|
||||
parent: &Arc<PrivateDirectory>,
|
||||
name: &str,
|
||||
recovery: Option<Arc<crate::daemon_client::UnavailablePublication>>,
|
||||
expect_no_receipt: bool,
|
||||
owner_state: Option<AppState>,
|
||||
) -> Result<(), DaemonSocketError> {
|
||||
let inspect_parent = parent.clone();
|
||||
let inspect_name = name.to_string();
|
||||
let Some(identity) =
|
||||
super::owner_work(move || inspect_parent.socket_identity(&inspect_name))
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|error| error.kind() == std::io::ErrorKind::InvalidInput)
|
||||
{
|
||||
DaemonSocketError::NotASocket {
|
||||
path: path.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
DaemonSocketError::State(error)
|
||||
}
|
||||
})?
|
||||
let expected = recovery.clone();
|
||||
let Some(identity) = super::owner_work(move || {
|
||||
if let Some(previous) = expected.as_ref() {
|
||||
anyhow::ensure!(
|
||||
previous.revalidate_all()?,
|
||||
"captured stale owner/socket changed before retirement"
|
||||
);
|
||||
return Ok(previous.publication.socket_identity);
|
||||
}
|
||||
if expect_no_receipt {
|
||||
anyhow::ensure!(
|
||||
inspect_parent
|
||||
.read_private_receipt(&format!("{inspect_name}.owner.json"), 16384)?
|
||||
.is_none(),
|
||||
"owner publication appeared before fresh endpoint bind; refusing replacement"
|
||||
);
|
||||
}
|
||||
inspect_parent.socket_identity(&inspect_name)
|
||||
})
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error
|
||||
.downcast_ref::<std::io::Error>()
|
||||
.is_some_and(|error| error.kind() == std::io::ErrorKind::InvalidInput)
|
||||
{
|
||||
DaemonSocketError::NotASocket {
|
||||
path: path.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
DaemonSocketError::State(error)
|
||||
}
|
||||
})?
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
@@ -1117,7 +1226,15 @@ mod platform {
|
||||
Ok(Err(error)) if error.kind() == std::io::ErrorKind::ConnectionRefused => {
|
||||
let retire_parent = parent.clone();
|
||||
let retire_name = name.to_string();
|
||||
if super::owner_work(move || retire_parent.retire_socket(&retire_name, identity))
|
||||
if socket_owner_work(owner_state, move || {
|
||||
if let Some(previous) = recovery.as_ref() {
|
||||
anyhow::ensure!(previous.revalidate_all()?, "captured stale owner/socket changed before retirement");
|
||||
} else if expect_no_receipt {
|
||||
anyhow::ensure!(retire_parent.read_private_receipt(&format!("{retire_name}.owner.json"), 16384)?.is_none(),
|
||||
"owner publication appeared before fresh endpoint retirement; refusing replacement");
|
||||
}
|
||||
retire_parent.retire_socket(&retire_name, identity)
|
||||
})
|
||||
.await
|
||||
.map_err(DaemonSocketError::State)?
|
||||
{
|
||||
@@ -1168,6 +1285,246 @@ mod platform {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use anyhow::Context as _;
|
||||
|
||||
struct FrontendLifetime {
|
||||
_held: Arc<()>,
|
||||
}
|
||||
impl crate::RuntimeOwnerFrontend for FrontendLifetime {
|
||||
fn validate_selection<'a>(
|
||||
&'a self,
|
||||
_selection: &'a crate::RuntimeOwnerFrontendSelection,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>>
|
||||
{
|
||||
Box::pin(async { anyhow::bail!("lifetime-only frontend cannot admit a command") })
|
||||
}
|
||||
fn serve(
|
||||
&self,
|
||||
_selection: crate::RuntimeOwnerFrontendSelection,
|
||||
_compatibility: AppState,
|
||||
_input: Box<dyn tokio::io::AsyncBufRead + Send + Unpin>,
|
||||
_output: Box<dyn tokio::io::AsyncWrite + Send + Unpin>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + '_>>
|
||||
{
|
||||
Box::pin(async { anyhow::bail!("lifetime-only frontend cannot serve a command") })
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_bootstrap_cancelled_mutations_and_retirement_retain_captured_frontend()
|
||||
-> Result<()> {
|
||||
// Proves transport custody. The TUI bootstrap tests separately
|
||||
// prove the captured manager retains its real store lease.
|
||||
let temporary = tempfile::Builder::new()
|
||||
.prefix("cw-ob-cancel-")
|
||||
.tempdir_in("/tmp")?;
|
||||
let root = temporary.path().canonicalize()?;
|
||||
let config = root.join("config.toml");
|
||||
std::fs::write(&config, "")?;
|
||||
for retirement in [false, true] {
|
||||
let mut state =
|
||||
build_state_off_runtime(Some(config.clone()), None, AppTransport::Socket)
|
||||
.await?;
|
||||
let held = Arc::new(());
|
||||
let observed_owner = Arc::downgrade(&held);
|
||||
state.owner_frontend = Some(Arc::new(FrontendLifetime { _held: held }));
|
||||
let parent_path = root.join(if retirement { "retirement" } else { "mutation" });
|
||||
let parent = Arc::new(PrivateDirectory::admit(&parent_path)?);
|
||||
let path = parent_path.join("daemon.sock");
|
||||
let socket = std::os::unix::net::UnixListener::bind(&path)?;
|
||||
let identity = parent
|
||||
.socket_identity("daemon.sock")?
|
||||
.context("socket identity")?;
|
||||
parent.protect_socket("daemon.sock", identity)?;
|
||||
drop(socket);
|
||||
parent.write_owned_file("daemon.sock.owner.json", b"{}", false)?;
|
||||
let (_, receipt_file) = parent
|
||||
.read_private_receipt("daemon.sock.owner.json", 16384)?
|
||||
.context("receipt")?;
|
||||
let (release, released) = std::sync::mpsc::channel();
|
||||
let (started, observed_start) = tokio::sync::oneshot::channel();
|
||||
let (finished, observed_finish) = tokio::sync::oneshot::channel();
|
||||
let worker = if retirement {
|
||||
let mut guard = SocketFileGuard {
|
||||
parent,
|
||||
name: "daemon.sock".into(),
|
||||
identity,
|
||||
receipt: Some(("daemon.sock.owner.json".into(), receipt_file)),
|
||||
owner_state: Some(state.clone()),
|
||||
retired: false,
|
||||
};
|
||||
let retire = guard.retirement().context("first retirement")?;
|
||||
drop(guard);
|
||||
tokio::spawn(super::super::owner_work(move || {
|
||||
let _ = started.send(());
|
||||
let _ = released.recv();
|
||||
let result = retire();
|
||||
let _ = finished.send(());
|
||||
result
|
||||
}))
|
||||
} else {
|
||||
tokio::spawn(socket_owner_work(Some(state.clone()), move || {
|
||||
let _ = started.send(());
|
||||
let _ = released.recv();
|
||||
parent.protect_socket("daemon.sock", identity)?;
|
||||
let _ = finished.send(());
|
||||
Ok(())
|
||||
}))
|
||||
};
|
||||
drop(state);
|
||||
tokio::time::timeout(Duration::from_secs(5), observed_start).await??;
|
||||
worker.abort();
|
||||
let _ = worker.await;
|
||||
assert!(
|
||||
observed_owner.upgrade().is_some(),
|
||||
"running filesystem mutation retains captured owner after waiter cancellation"
|
||||
);
|
||||
release.send(())?;
|
||||
tokio::time::timeout(Duration::from_secs(5), observed_finish).await??;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
while observed_owner.upgrade().is_some() {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
if retirement {
|
||||
assert!(!path.exists());
|
||||
assert!(!path.with_file_name("daemon.sock.owner.json").exists());
|
||||
} else {
|
||||
assert!(path.exists(), "protection alone preserves publication");
|
||||
assert!(path.with_file_name("daemon.sock.owner.json").exists());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn owner_bootstrap_withdraws_dead_receipt_before_replacement_listener_can_answer()
|
||||
-> Result<()> {
|
||||
struct OwnedChild(std::process::Child);
|
||||
impl Drop for OwnedChild {
|
||||
fn drop(&mut self) {
|
||||
if self.0.try_wait().ok().flatten().is_none() {
|
||||
let _ = self.0.kill();
|
||||
}
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
let temporary = tempfile::Builder::new()
|
||||
.prefix("cw-ob-order-")
|
||||
.tempdir_in("/tmp")?;
|
||||
let root = temporary.path().canonicalize()?;
|
||||
let path = root.join("run/daemon.sock");
|
||||
let config = root.join("config.toml");
|
||||
std::fs::write(&config, "")?;
|
||||
let parent = PrivateDirectory::admit(path.parent().unwrap())?;
|
||||
let stale = std::os::unix::net::UnixListener::bind(&path)?;
|
||||
let identity = parent.socket_identity("daemon.sock")?.unwrap();
|
||||
parent.protect_socket("daemon.sock", identity)?;
|
||||
drop(stale);
|
||||
let mut child = OwnedChild(
|
||||
std::process::Command::new("/bin/sleep")
|
||||
.arg("60")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()?,
|
||||
);
|
||||
let mut owner = codewhale_protocol::RuntimeOwnerReceipt {
|
||||
version: 1,
|
||||
data_dir: root.join("runtime"),
|
||||
execution_scope: "ordering-store".into(),
|
||||
lease_generation: "ordering-old".into(),
|
||||
pid: child.0.id(),
|
||||
process_start: super::super::capture_process_start(child.0.id()).await?,
|
||||
principal: PrivateDirectory::current_user_id().to_string(),
|
||||
socket_path: path.clone(),
|
||||
config_path: Some(config.clone()),
|
||||
};
|
||||
child.0.kill()?;
|
||||
child.0.wait()?;
|
||||
parent.write_owned_file(
|
||||
"daemon.sock.owner.json",
|
||||
&serde_json::to_vec(&owner)?,
|
||||
false,
|
||||
)?;
|
||||
let crate::daemon_client::HostOwnerProbe::Unavailable(captured) =
|
||||
crate::daemon_client::probe_owner_for_host_startup(
|
||||
Some(config.clone()),
|
||||
Some(path.clone()),
|
||||
)
|
||||
.await?
|
||||
else {
|
||||
anyhow::bail!("dead owner must be captured")
|
||||
};
|
||||
let observed = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let observed_hook = observed.clone();
|
||||
let selected = path.clone();
|
||||
let selected_config = config.clone();
|
||||
let stale_capture = captured.clone();
|
||||
BEFORE_OWNER_REBIND.with(|slot| {
|
||||
*slot.borrow_mut() = Some(Box::new(move |endpoint| {
|
||||
assert_eq!(endpoint, selected);
|
||||
assert!(!selected.with_file_name("daemon.sock.owner.json").exists());
|
||||
let endpoint = selected.clone();
|
||||
std::thread::spawn(move || {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap()
|
||||
.block_on(async {
|
||||
assert!(
|
||||
!stale_capture.revalidate().await.unwrap(),
|
||||
"withdrawn capture must never authorize a replacement"
|
||||
);
|
||||
assert!(matches!(
|
||||
crate::daemon_client::probe_owner_for_host_startup(
|
||||
Some(selected_config),
|
||||
Some(endpoint),
|
||||
)
|
||||
.await
|
||||
.unwrap(),
|
||||
crate::daemon_client::HostOwnerProbe::Absent
|
||||
));
|
||||
});
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
// Force bind failure after the exact observation point;
|
||||
// the product must preserve this task-owned replacement.
|
||||
drop(std::os::unix::net::UnixListener::bind(&selected).unwrap());
|
||||
observed_hook.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}));
|
||||
});
|
||||
owner.pid = std::process::id();
|
||||
owner.process_start = super::super::capture_process_start(owner.pid).await?;
|
||||
owner.lease_generation = "ordering-new".into();
|
||||
let result = crate::bind_runtime_frontends(
|
||||
Some(config),
|
||||
None,
|
||||
owner,
|
||||
crate::RuntimeOwnerRouting {
|
||||
endpoint: "127.0.0.1:1".parse()?,
|
||||
workspace: None,
|
||||
workers: None,
|
||||
mobile: false,
|
||||
web: false,
|
||||
acp: false,
|
||||
acp_only: false,
|
||||
},
|
||||
None,
|
||||
Some(captured),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "injected replacement prevents new bind");
|
||||
assert!(observed.load(std::sync::atomic::Ordering::SeqCst));
|
||||
assert!(
|
||||
parent.socket_identity("daemon.sock")?.is_some(),
|
||||
"replacement endpoint preserved"
|
||||
);
|
||||
assert!(!path.with_file_name("daemon.sock.owner.json").exists());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn client(name: &str) -> ClientIdentity {
|
||||
ClientIdentity {
|
||||
|
||||
@@ -610,6 +610,7 @@ pub async fn bind_runtime_owner(
|
||||
acp_only: false,
|
||||
},
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
Ok(daemon)
|
||||
@@ -623,6 +624,7 @@ pub async fn bind_runtime_frontends(
|
||||
owner: codewhale_protocol::RuntimeOwnerReceipt,
|
||||
routing: RuntimeOwnerRouting,
|
||||
owner_frontend: Option<Arc<dyn RuntimeOwnerFrontend>>,
|
||||
recovery: Option<Arc<daemon_client::UnavailablePublication>>,
|
||||
) -> Result<(daemon_socket::DaemonSocket, AppState)> {
|
||||
anyhow::ensure!(
|
||||
owner.version == 1 && owner.pid == std::process::id() && !owner.lease_generation.is_empty(),
|
||||
@@ -660,7 +662,16 @@ pub async fn bind_runtime_frontends(
|
||||
state.captured_routing = Some(routing);
|
||||
state.owner_frontend = owner_frontend;
|
||||
*state.runtime_bridge.lock().await = Some(Arc::new(Mutex::new(bridge)));
|
||||
let daemon = daemon_socket::bind_captured_owner(state.clone(), owner).await?;
|
||||
#[cfg(unix)]
|
||||
let daemon = daemon_socket::bind_captured_owner(state.clone(), owner, recovery).await?;
|
||||
#[cfg(windows)]
|
||||
let daemon = {
|
||||
anyhow::ensure!(
|
||||
recovery.is_none(),
|
||||
"Windows stale-owner recovery is not qualified; refusing fallback"
|
||||
);
|
||||
daemon_socket::bind_captured_owner(state.clone(), owner).await?
|
||||
};
|
||||
Ok((daemon, state))
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,277 @@ impl Drop for Harness {
|
||||
}
|
||||
}
|
||||
|
||||
struct OwnedBootstrapChild(std::process::Child);
|
||||
impl Drop for OwnedBootstrapChild {
|
||||
fn drop(&mut self) {
|
||||
if self.0.try_wait().ok().flatten().is_none() {
|
||||
let _ = self.0.kill();
|
||||
}
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// Transport-only publication fixture; actual store-lease proof lives in TUI.
|
||||
struct BootstrapPublicationFixture {
|
||||
harness: Harness,
|
||||
parent: codewhale_config::private_directory::PrivateDirectory,
|
||||
owner: codewhale_protocol::RuntimeOwnerReceipt,
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
impl BootstrapPublicationFixture {
|
||||
async fn new(label: &str, dead: bool, reused: bool) -> Self {
|
||||
use codewhale_config::private_directory::PrivateDirectory;
|
||||
let harness = Harness::new(label);
|
||||
let parent = PrivateDirectory::admit(harness.socket_path.parent().unwrap()).unwrap();
|
||||
let socket = std::os::unix::net::UnixListener::bind(&harness.socket_path).unwrap();
|
||||
let identity = parent.socket_identity("daemon.sock").unwrap().unwrap();
|
||||
parent.protect_socket("daemon.sock", identity).unwrap();
|
||||
drop(socket);
|
||||
let (pid, mut process_start) = if dead {
|
||||
let mut child = OwnedBootstrapChild(
|
||||
std::process::Command::new("/bin/sleep")
|
||||
.arg("60")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let pid = child.0.id();
|
||||
let start = codewhale_app_server::daemon_socket::capture_process_start(pid)
|
||||
.await
|
||||
.unwrap();
|
||||
child.0.kill().unwrap();
|
||||
child.0.wait().unwrap();
|
||||
(pid, start)
|
||||
} else {
|
||||
(
|
||||
std::process::id(),
|
||||
codewhale_app_server::daemon_socket::capture_process_start(std::process::id())
|
||||
.await
|
||||
.unwrap(),
|
||||
)
|
||||
};
|
||||
if reused {
|
||||
process_start.push_str("-different-generation");
|
||||
}
|
||||
let owner = codewhale_protocol::RuntimeOwnerReceipt {
|
||||
version: 1,
|
||||
data_dir: harness.root.join("runtime"),
|
||||
execution_scope: "bootstrap-transport-store".into(),
|
||||
lease_generation: "bootstrap-dead-generation".into(),
|
||||
pid,
|
||||
process_start,
|
||||
principal: PrivateDirectory::current_user_id().to_string(),
|
||||
socket_path: harness.socket_path.clone(),
|
||||
config_path: harness.options().config_path,
|
||||
};
|
||||
let bytes = serde_json::to_vec(&owner).unwrap();
|
||||
parent
|
||||
.write_owned_file("daemon.sock.owner.json", &bytes, false)
|
||||
.unwrap();
|
||||
Self {
|
||||
harness,
|
||||
parent,
|
||||
owner,
|
||||
bytes,
|
||||
}
|
||||
}
|
||||
|
||||
async fn capture(
|
||||
&self,
|
||||
) -> std::sync::Arc<codewhale_app_server::daemon_client::UnavailablePublication> {
|
||||
match codewhale_app_server::daemon_client::probe_owner_for_host_startup(
|
||||
self.owner.config_path.clone(),
|
||||
Some(self.owner.socket_path.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
{
|
||||
codewhale_app_server::daemon_client::HostOwnerProbe::Unavailable(publication) => {
|
||||
publication
|
||||
}
|
||||
_ => panic!("refused private endpoint must be captured before bootstrap"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn bind_replacement(
|
||||
&self,
|
||||
recovery: Option<
|
||||
std::sync::Arc<codewhale_app_server::daemon_client::UnavailablePublication>,
|
||||
>,
|
||||
) -> anyhow::Result<codewhale_app_server::daemon_socket::DaemonSocket> {
|
||||
let mut owner = self.owner.clone();
|
||||
owner.pid = std::process::id();
|
||||
owner.process_start =
|
||||
codewhale_app_server::daemon_socket::capture_process_start(owner.pid).await?;
|
||||
owner.lease_generation = "bootstrap-new-generation".into();
|
||||
let (daemon, _) = codewhale_app_server::bind_runtime_frontends(
|
||||
owner.config_path.clone(),
|
||||
None,
|
||||
owner,
|
||||
codewhale_app_server::RuntimeOwnerRouting {
|
||||
endpoint: "127.0.0.1:1".parse().unwrap(),
|
||||
workspace: None,
|
||||
workers: None,
|
||||
mobile: false,
|
||||
web: false,
|
||||
acp: false,
|
||||
acp_only: false,
|
||||
},
|
||||
None,
|
||||
recovery,
|
||||
)
|
||||
.await?;
|
||||
Ok(daemon)
|
||||
}
|
||||
|
||||
fn assert_publication(&self, bytes: &[u8]) {
|
||||
assert_eq!(
|
||||
self.parent
|
||||
.read_private_receipt("daemon.sock.owner.json", 16384)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.0,
|
||||
bytes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_bootstrap_unavailable_is_host_only_and_read_only() {
|
||||
let fixture = BootstrapPublicationFixture::new("boot-read", true, false).await;
|
||||
let identity = fixture.parent.socket_identity("daemon.sock").unwrap();
|
||||
let captured = fixture.capture().await;
|
||||
assert_eq!(captured.receipt(), &fixture.owner);
|
||||
assert!(captured.revalidate().await.unwrap());
|
||||
assert!(
|
||||
codewhale_app_server::daemon_client::connect_if_published(
|
||||
fixture.owner.config_path.clone(),
|
||||
Some(fixture.owner.socket_path.clone()),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
codewhale_app_server::daemon_client::connect(
|
||||
fixture.owner.config_path.clone(),
|
||||
Some(fixture.owner.socket_path.clone()),
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
fixture.assert_publication(&fixture.bytes);
|
||||
assert_eq!(
|
||||
fixture.parent.socket_identity("daemon.sock").unwrap(),
|
||||
identity
|
||||
);
|
||||
assert!(
|
||||
!fixture.owner.data_dir.exists(),
|
||||
"standalone control must not open or mint a store"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_bootstrap_live_process_and_reused_pid_refuse_retirement() {
|
||||
for reused in [false, true] {
|
||||
let fixture = BootstrapPublicationFixture::new("boot-live", false, reused).await;
|
||||
let captured = fixture.capture().await;
|
||||
let error = captured.revalidate().await.err().unwrap().to_string();
|
||||
assert!(
|
||||
error.contains(if reused { "reused" } else { "still present" }),
|
||||
"{error}"
|
||||
);
|
||||
assert!(fixture.bind_replacement(Some(captured)).await.is_err());
|
||||
fixture.assert_publication(&fixture.bytes);
|
||||
assert!(
|
||||
fixture
|
||||
.parent
|
||||
.socket_identity("daemon.sock")
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_bootstrap_equal_bytes_do_not_authorize_replaced_receipt() {
|
||||
let fixture = BootstrapPublicationFixture::new("boot-file", true, false).await;
|
||||
let captured = fixture.capture().await;
|
||||
let identity = fixture.parent.socket_identity("daemon.sock").unwrap();
|
||||
std::fs::rename(
|
||||
fixture
|
||||
.harness
|
||||
.socket_path
|
||||
.with_file_name("daemon.sock.owner.json"),
|
||||
fixture.harness.socket_path.with_file_name("old.owner.json"),
|
||||
)
|
||||
.unwrap();
|
||||
fixture
|
||||
.parent
|
||||
.write_owned_file("daemon.sock.owner.json", &fixture.bytes, false)
|
||||
.unwrap();
|
||||
assert!(!captured.revalidate().await.unwrap());
|
||||
assert!(fixture.bind_replacement(Some(captured)).await.is_err());
|
||||
fixture.assert_publication(&fixture.bytes);
|
||||
assert_eq!(
|
||||
fixture.parent.socket_identity("daemon.sock").unwrap(),
|
||||
identity
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_bootstrap_same_inode_content_change_is_preserved() {
|
||||
let fixture = BootstrapPublicationFixture::new("boot-edit", true, false).await;
|
||||
let captured = fixture.capture().await;
|
||||
let mut edited = fixture.bytes.clone();
|
||||
edited.push(b'\n');
|
||||
std::fs::write(
|
||||
fixture
|
||||
.harness
|
||||
.socket_path
|
||||
.with_file_name("daemon.sock.owner.json"),
|
||||
&edited,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!captured.revalidate().await.unwrap());
|
||||
assert!(fixture.bind_replacement(Some(captured)).await.is_err());
|
||||
fixture.assert_publication(&edited);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_bootstrap_replaced_socket_is_preserved() {
|
||||
let fixture = BootstrapPublicationFixture::new("boot-sock", true, false).await;
|
||||
let captured = fixture.capture().await;
|
||||
std::fs::rename(
|
||||
&fixture.harness.socket_path,
|
||||
fixture.harness.socket_path.with_file_name("old.sock"),
|
||||
)
|
||||
.unwrap();
|
||||
let replacement = std::os::unix::net::UnixListener::bind(&fixture.harness.socket_path).unwrap();
|
||||
let identity = fixture.parent.socket_identity("daemon.sock").unwrap();
|
||||
drop(replacement);
|
||||
assert!(!captured.revalidate().await.unwrap());
|
||||
assert!(fixture.bind_replacement(Some(captured)).await.is_err());
|
||||
assert_eq!(
|
||||
fixture.parent.socket_identity("daemon.sock").unwrap(),
|
||||
identity
|
||||
);
|
||||
fixture.assert_publication(&fixture.bytes);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_bootstrap_fresh_bind_cannot_retire_uncaptured_publication() {
|
||||
let fixture = BootstrapPublicationFixture::new("boot-fresh", true, false).await;
|
||||
let identity = fixture.parent.socket_identity("daemon.sock").unwrap();
|
||||
assert!(fixture.bind_replacement(None).await.is_err());
|
||||
fixture.assert_publication(&fixture.bytes);
|
||||
assert_eq!(
|
||||
fixture.parent.socket_identity("daemon.sock").unwrap(),
|
||||
identity
|
||||
);
|
||||
}
|
||||
|
||||
struct Client {
|
||||
reader: BufReader<OwnedReadHalf>,
|
||||
writer: OwnedWriteHalf,
|
||||
@@ -790,6 +1061,7 @@ async fn canonical_cli_scoped_resume_fork_keep_exact_owner_workers_and_refuse_wr
|
||||
acp_only: false,
|
||||
},
|
||||
Some(frontend),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -26,6 +26,22 @@ pub struct PrivateDirectory {
|
||||
pub(crate) _component_handles: Vec<File>,
|
||||
}
|
||||
|
||||
/// A retained file was retired before it could be validated. This is still a
|
||||
/// refusal; an owner observer may re-observe, but must never accept this file.
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug)]
|
||||
pub struct UnlinkedPrivateFile;
|
||||
|
||||
#[cfg(unix)]
|
||||
impl std::fmt::Display for UnlinkedPrivateFile {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter.write_str("private file was unlinked before validation")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
impl std::error::Error for UnlinkedPrivateFile {}
|
||||
|
||||
impl PrivateDirectory {
|
||||
pub fn open(directory: &Path) -> Result<Self> {
|
||||
open_owned_directory(directory, true, true)
|
||||
@@ -287,38 +303,45 @@ impl PrivateDirectory {
|
||||
.context("atomically replacing xAI OAuth credentials");
|
||||
}
|
||||
} else {
|
||||
// `linkat` installs the unique generation without clobbering an
|
||||
// existing path. The temporary link is removed immediately.
|
||||
// SAFETY: all descriptors/names remain valid for both calls.
|
||||
if unsafe {
|
||||
libc::linkat(
|
||||
self.directory_handle.as_raw_fd(),
|
||||
temporary.as_ptr(),
|
||||
self.directory_handle.as_raw_fd(),
|
||||
target.as_ptr(),
|
||||
0,
|
||||
)
|
||||
} != 0
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
// Exclusive rename publishes one link atomically. A hard-link
|
||||
// publication briefly exposes two links to concurrent readers.
|
||||
self.move_no_replace(&temp_name, name)?;
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
{
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.context("installing a new xAI OAuth generation without replacement");
|
||||
}
|
||||
// SAFETY: same descriptor and staging name as the `linkat` above.
|
||||
if unsafe {
|
||||
libc::unlinkat(self.directory_handle.as_raw_fd(), temporary.as_ptr(), 0)
|
||||
} != 0
|
||||
{
|
||||
let error = std::io::Error::last_os_error();
|
||||
// The target and staging name still reference the same
|
||||
// inode. Remove the just-installed target so the generic
|
||||
// error cleanup can safely retire the single remaining
|
||||
// staging link instead of leaving an inert secret with
|
||||
// link count two.
|
||||
// SAFETY: same descriptor; `target` was just installed above.
|
||||
unsafe {
|
||||
libc::unlinkat(self.directory_handle.as_raw_fd(), target.as_ptr(), 0)
|
||||
};
|
||||
return Err(error).context("removing xAI OAuth generation staging link");
|
||||
// `linkat` installs the unique generation without clobbering an
|
||||
// existing path. The temporary link is removed immediately.
|
||||
// SAFETY: all descriptors/names remain valid for both calls.
|
||||
if unsafe {
|
||||
libc::linkat(
|
||||
self.directory_handle.as_raw_fd(),
|
||||
temporary.as_ptr(),
|
||||
self.directory_handle.as_raw_fd(),
|
||||
target.as_ptr(),
|
||||
0,
|
||||
)
|
||||
} != 0
|
||||
{
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.context("installing a new xAI OAuth generation without replacement");
|
||||
}
|
||||
// SAFETY: same descriptor and staging name as the `linkat` above.
|
||||
if unsafe {
|
||||
libc::unlinkat(self.directory_handle.as_raw_fd(), temporary.as_ptr(), 0)
|
||||
} != 0
|
||||
{
|
||||
let error = std::io::Error::last_os_error();
|
||||
// The target and staging name still reference the same
|
||||
// inode. Remove the just-installed target so the generic
|
||||
// error cleanup can safely retire the single remaining
|
||||
// staging link instead of leaving an inert secret with
|
||||
// link count two.
|
||||
// SAFETY: same descriptor; `target` was just installed above.
|
||||
unsafe {
|
||||
libc::unlinkat(self.directory_handle.as_raw_fd(), target.as_ptr(), 0)
|
||||
};
|
||||
return Err(error).context("removing xAI OAuth generation staging link");
|
||||
}
|
||||
}
|
||||
}
|
||||
self.directory_handle
|
||||
@@ -401,6 +424,7 @@ pub(crate) fn validate_owned_file_handle(file: &File, path: &Path) -> Result<fs:
|
||||
metadata.uid() == unsafe { libc::geteuid() },
|
||||
"xAI OAuth file must be owned by the current user"
|
||||
);
|
||||
anyhow::ensure!(metadata.nlink() != 0, UnlinkedPrivateFile);
|
||||
anyhow::ensure!(
|
||||
metadata.nlink() == 1,
|
||||
"xAI OAuth file must not have multiple filesystem links"
|
||||
@@ -1286,7 +1310,7 @@ impl PrivateDirectory {
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn move_socket_no_replace(&self, from: &str, to: &str) -> Result<()> {
|
||||
fn move_no_replace(&self, from: &str, to: &str) -> Result<()> {
|
||||
use std::os::fd::AsRawFd as _;
|
||||
validate_private_basename(from)?;
|
||||
validate_private_basename(to)?;
|
||||
@@ -1318,13 +1342,13 @@ impl PrivateDirectory {
|
||||
};
|
||||
if result != 0 {
|
||||
return Err(std::io::Error::last_os_error())
|
||||
.context("retiring endpoint without replacement");
|
||||
.context("moving private entry without replacement");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
|
||||
fn move_socket_no_replace(&self, _from: &str, _to: &str) -> Result<()> {
|
||||
fn move_no_replace(&self, _from: &str, _to: &str) -> Result<()> {
|
||||
bail!("exclusive endpoint retirement is unsupported on this platform")
|
||||
}
|
||||
|
||||
@@ -1340,9 +1364,9 @@ impl PrivateDirectory {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(),
|
||||
COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
);
|
||||
self.move_socket_no_replace(name, &retired)?;
|
||||
self.move_no_replace(name, &retired)?;
|
||||
if self.socket_identity(&retired)? != Some(expected) {
|
||||
let restored = self.move_socket_no_replace(&retired, name);
|
||||
let restored = self.move_no_replace(&retired, name);
|
||||
return Err(anyhow::anyhow!(
|
||||
"endpoint identity changed; replacement preserved (restore: {restored:?})"
|
||||
));
|
||||
@@ -1400,6 +1424,75 @@ mod tests {
|
||||
assert!(parent.read_private_receipt("alias.json", 8).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_receipt_opened_before_retirement_is_rejected_as_unlinked_not_hardlinked() {
|
||||
let root = root();
|
||||
let parent = PrivateDirectory::admit(&root.path().join("run")).unwrap();
|
||||
parent
|
||||
.write_owned_file("owner.json", b"receipt", false)
|
||||
.unwrap();
|
||||
let file = parent
|
||||
.open_owned_file_for_read("owner.json")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(parent.remove_raw("owner.json").unwrap());
|
||||
let error = parent
|
||||
.read_private_receipt_file("owner.json", 8, file)
|
||||
.unwrap_err();
|
||||
assert!(error.is::<UnlinkedPrivateFile>());
|
||||
|
||||
parent
|
||||
.write_owned_file("owner.json", b"receipt", false)
|
||||
.unwrap();
|
||||
fs::hard_link(
|
||||
parent.directory.join("owner.json"),
|
||||
parent.directory.join("alias.json"),
|
||||
)
|
||||
.unwrap();
|
||||
let error = parent.read_private_receipt("owner.json", 8).unwrap_err();
|
||||
assert!(
|
||||
!error.is::<UnlinkedPrivateFile>(),
|
||||
"a linked receipt must remain unsafe, not retryable"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn private_exclusive_publication_keeps_one_link_and_preserves_existing_destination() {
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let root = root();
|
||||
let parent = PrivateDirectory::admit(&root.path().join("run")).unwrap();
|
||||
let mut staging = parent.open_internal_file("staging").unwrap();
|
||||
staging.write_all(b"receipt").unwrap();
|
||||
staging.sync_all().unwrap();
|
||||
parent.move_no_replace("staging", "owner.json").unwrap();
|
||||
assert_eq!(staging.metadata().unwrap().nlink(), 1);
|
||||
assert!(
|
||||
parent
|
||||
.open_owned_file_for_read("staging")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
parent
|
||||
.write_owned_file("owner.json", b"replacement", false)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
parent
|
||||
.read_private_receipt("owner.json", 8)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.0,
|
||||
b"receipt"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_dir(&parent.directory).unwrap().count(),
|
||||
1,
|
||||
"failed publication must clean only its own staging file"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn owner_receipt_retirement_preserves_a_replacement_and_uses_captured_file() {
|
||||
@@ -1456,6 +1549,45 @@ mod tests {
|
||||
assert!(unix_process_start(u32::MAX).is_err());
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
#[test]
|
||||
fn unix_process_status_requires_positive_kernel_absence() {
|
||||
assert_eq!(
|
||||
unix_process_status(std::process::id()).unwrap(),
|
||||
UnixProcessStatus::Present {
|
||||
start: unix_process_start(std::process::id()).unwrap()
|
||||
}
|
||||
);
|
||||
let mut child = std::process::Command::new("/bin/sh")
|
||||
.args(["-c", "exit 0"])
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let pid = child.id();
|
||||
child.wait().unwrap();
|
||||
assert_eq!(unix_process_status(pid).unwrap(), UnixProcessStatus::Absent);
|
||||
assert!(unix_process_status(0).is_err());
|
||||
assert!(unix_process_status(u32::MAX).is_err());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unix_process_status_never_converts_uncertainty_to_death() {
|
||||
assert!(unix_pid_is_absent(
|
||||
-1,
|
||||
&std::io::Error::from_raw_os_error(libc::ESRCH)
|
||||
));
|
||||
for code in [libc::EPERM, libc::EACCES, libc::EINVAL] {
|
||||
assert!(!unix_pid_is_absent(
|
||||
-1,
|
||||
&std::io::Error::from_raw_os_error(code)
|
||||
));
|
||||
}
|
||||
assert!(!unix_pid_is_absent(
|
||||
0,
|
||||
&std::io::Error::from_raw_os_error(libc::ESRCH)
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn linux_process_generation_survives_nondumpable_hardening() {
|
||||
@@ -1584,7 +1716,7 @@ mod tests {
|
||||
let destination_identity = parent.socket_identity("retired.sock").unwrap().unwrap();
|
||||
|
||||
let error = parent
|
||||
.move_socket_no_replace("owner.sock", "retired.sock")
|
||||
.move_no_replace("owner.sock", "retired.sock")
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
error.downcast_ref::<std::io::Error>().unwrap().kind(),
|
||||
@@ -1630,6 +1762,42 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// A current-user process identity or positive kernel absence. Query failures
|
||||
/// remain errors; an unreadable process record never establishes death.
|
||||
#[cfg(unix)]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum UnixProcessStatus {
|
||||
Present { start: String },
|
||||
Absent,
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn unix_pid_is_absent(result: i32, error: &std::io::Error) -> bool {
|
||||
result == -1 && error.raw_os_error() == Some(libc::ESRCH)
|
||||
}
|
||||
|
||||
/// Read on the existing bounded owner worker. Signal zero never terminates a
|
||||
/// process; only ESRCH after validating a positive PID proves absence.
|
||||
#[cfg(unix)]
|
||||
pub fn unix_process_status(pid: u32) -> Result<UnixProcessStatus> {
|
||||
let kernel_pid = i32::try_from(pid).context("invalid local process PID")?;
|
||||
anyhow::ensure!(kernel_pid > 0, "invalid local process PID");
|
||||
match unix_process_start(pid) {
|
||||
Ok(start) => Ok(UnixProcessStatus::Present { start }),
|
||||
Err(identity_error) => {
|
||||
// SAFETY: a validated positive PID and signal zero only query
|
||||
// existence. No process-group selection or signal is possible.
|
||||
let result = unsafe { libc::kill(kernel_pid, 0) };
|
||||
let error = std::io::Error::last_os_error();
|
||||
if unix_pid_is_absent(result, &error) {
|
||||
Ok(UnixProcessStatus::Absent)
|
||||
} else {
|
||||
Err(identity_error).context("selected process identity/liveness is uncertain")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Kernel process creation identity for the authenticated local Unix peer.
|
||||
/// Run on the existing bounded owner worker; PID alone is never sufficient.
|
||||
#[cfg(unix)]
|
||||
@@ -1770,11 +1938,20 @@ impl PrivateDirectory {
|
||||
/// The same anchored owned-file opener used by credentials, with a bounded
|
||||
/// read and retained object for exact receipt retirement.
|
||||
pub fn read_private_receipt(&self, name: &str, max: usize) -> Result<Option<(Vec<u8>, File)>> {
|
||||
use std::io::Read as _;
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let Some(mut file) = self.open_owned_file_for_read(name)? else {
|
||||
let Some(file) = self.open_owned_file_for_read(name)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
self.read_private_receipt_file(name, max, file).map(Some)
|
||||
}
|
||||
|
||||
fn read_private_receipt_file(
|
||||
&self,
|
||||
name: &str,
|
||||
max: usize,
|
||||
mut file: File,
|
||||
) -> Result<(Vec<u8>, File)> {
|
||||
use std::io::Read as _;
|
||||
use std::os::unix::fs::MetadataExt as _;
|
||||
let before = validate_owned_file_handle(&file, &self.directory.join(name))?;
|
||||
anyhow::ensure!(
|
||||
before.mode() & 0o077 == 0 && before.len() <= u64::try_from(max)?,
|
||||
@@ -1797,7 +1974,7 @@ impl PrivateDirectory {
|
||||
&& before.mtime_nsec() == after.mtime_nsec(),
|
||||
"private owner receipt changed while reading"
|
||||
);
|
||||
Ok(Some((bytes, file)))
|
||||
Ok((bytes, file))
|
||||
}
|
||||
|
||||
pub fn retire_private_receipt(&self, name: &str, captured: &File) -> Result<bool> {
|
||||
@@ -1815,13 +1992,13 @@ impl PrivateDirectory {
|
||||
std::process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
|
||||
);
|
||||
self.move_socket_no_replace(name, &retired)?;
|
||||
self.move_no_replace(name, &retired)?;
|
||||
let Some(moved) = self.open_owned_file_for_read(&retired)? else {
|
||||
bail!("private receipt retirement is uncertain")
|
||||
};
|
||||
let moved_metadata = validate_owned_file_handle(&moved, &self.directory.join(&retired))?;
|
||||
if (expected.dev(), expected.ino()) != (moved_metadata.dev(), moved_metadata.ino()) {
|
||||
let restored = self.move_socket_no_replace(&retired, name);
|
||||
let restored = self.move_no_replace(&retired, name);
|
||||
bail!("private receipt changed; retaining replacement (restore: {restored:?})");
|
||||
}
|
||||
self.remove_raw(&retired)?;
|
||||
|
||||
@@ -1183,11 +1183,14 @@ mod tests {
|
||||
};
|
||||
let plugins = Arc::new(crate::plugins::PluginRegistry::empty(&workspace));
|
||||
let manager = Arc::new(if acp {
|
||||
RuntimeThreadManager::open_acp(
|
||||
RuntimeThreadManager::open_host(
|
||||
config.clone(),
|
||||
workspace.clone(),
|
||||
manager_config,
|
||||
plugins,
|
||||
crate::core::engine::EngineHostProfile::Acp,
|
||||
None,
|
||||
None,
|
||||
)?
|
||||
} else {
|
||||
RuntimeThreadManager::open_with_plugin_registry(
|
||||
@@ -1367,6 +1370,7 @@ mod tests {
|
||||
acp_only: base_acp,
|
||||
},
|
||||
Some(frontend),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
let shutdown = daemon.shutdown_handle();
|
||||
|
||||
@@ -874,8 +874,8 @@ fn update_bundle(
|
||||
PluginMutationOutcome::Updated => {
|
||||
let name = receipt.name.clone();
|
||||
let mut output = format!(
|
||||
"Updated plugin '{name}'. Its content changed, so the previous trust receipt no \
|
||||
longer matches — review and trust it again before enabling.\n"
|
||||
"Updated plugin '{name}'. Review its current capabilities and trust it \
|
||||
if needed before enabling.\n"
|
||||
);
|
||||
if let Some(review) = review_bundle(presentation, plugin, &name).message {
|
||||
output.push('\n');
|
||||
|
||||
@@ -603,10 +603,10 @@ fn install_update_uninstall_verbs_drive_the_guided_trust_flow() {
|
||||
.exists()
|
||||
);
|
||||
|
||||
// Local-path installs cannot be updated from the network.
|
||||
// An unchanged local source succeeds without changing the review token.
|
||||
let update = plugins_with_kimi_home_override(&mut app, Some("update installed-demo"), None);
|
||||
assert!(update.is_error);
|
||||
assert!(update.message.unwrap().contains("local path"));
|
||||
assert!(!update.is_error, "{:?}", update.message);
|
||||
assert!(update.message.unwrap().contains("already up to date"));
|
||||
|
||||
let arg = confirmation.trim_start_matches("/plugin ").to_string();
|
||||
assert!(!plugins_with_kimi_home_override(&mut app, Some(&arg), None).is_error);
|
||||
@@ -616,6 +616,30 @@ fn install_update_uninstall_verbs_drive_the_guided_trust_flow() {
|
||||
);
|
||||
assert!(app.plugin_registry.is_active("installed-demo"));
|
||||
|
||||
fs::write(
|
||||
source.join("plugin.toml"),
|
||||
"schema_version = 1\n[plugin]\nname = \"installed-demo\"\nversion = \"2.0.0\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let updated =
|
||||
plugins_with_kimi_home_override(&mut app, Some("update installed-demo"), None);
|
||||
assert!(!updated.is_error, "{:?}", updated.message);
|
||||
let message = updated.message.unwrap();
|
||||
let current_confirmation = message
|
||||
.lines()
|
||||
.find(|line| line.starts_with("/plugin trust installed-demo "))
|
||||
.expect("changed local content must route into its current review");
|
||||
assert_ne!(current_confirmation, confirmation);
|
||||
assert!(!app.plugin_registry.is_active("installed-demo"));
|
||||
assert!(plugins_with_kimi_home_override(&mut app, Some(&arg), None).is_error);
|
||||
let current_arg = current_confirmation.trim_start_matches("/plugin ");
|
||||
assert!(!plugins_with_kimi_home_override(&mut app, Some(current_arg), None).is_error);
|
||||
assert!(
|
||||
!plugins_with_kimi_home_override(&mut app, Some("enable installed-demo"), None)
|
||||
.is_error
|
||||
);
|
||||
assert!(app.plugin_registry.is_active("installed-demo"));
|
||||
|
||||
// Uninstall requires disabled, then removes bits and prunes state.
|
||||
let refused =
|
||||
plugins_with_kimi_home_override(&mut app, Some("uninstall installed-demo"), None);
|
||||
|
||||
@@ -430,7 +430,7 @@ fn verify_snapshot(root: &Path, files: &BTreeMap<PathBuf, &[u8]>) -> io::Result<
|
||||
/// Atomic no-replace directory publication. A check followed by ordinary Unix
|
||||
/// rename is insufficient: rename is allowed to replace an existing empty dir.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn publish_snapshot(source: &Path, destination: &Path) -> io::Result<()> {
|
||||
pub(super) fn publish_snapshot(source: &Path, destination: &Path) -> io::Result<()> {
|
||||
use std::ffi::CString;
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
|
||||
@@ -461,7 +461,7 @@ fn publish_snapshot(source: &Path, destination: &Path) -> io::Result<()> {
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn publish_snapshot(source: &Path, destination: &Path) -> io::Result<()> {
|
||||
pub(super) fn publish_snapshot(source: &Path, destination: &Path) -> io::Result<()> {
|
||||
use std::os::windows::ffi::OsStrExt as _;
|
||||
use windows::Win32::Storage::FileSystem::{MOVEFILE_WRITE_THROUGH, MoveFileExW};
|
||||
use windows::core::PCWSTR;
|
||||
@@ -485,7 +485,7 @@ fn publish_snapshot(source: &Path, destination: &Path) -> io::Result<()> {
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
|
||||
fn publish_snapshot(_source: &Path, _destination: &Path) -> io::Result<()> {
|
||||
pub(super) fn publish_snapshot(_source: &Path, _destination: &Path) -> io::Result<()> {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"atomic built-in snapshot publication is unsupported on this platform",
|
||||
|
||||
@@ -173,6 +173,23 @@ pub(crate) fn discover_with_context(
|
||||
)
|
||||
}
|
||||
|
||||
// These exact operation-name shapes are reserved by stage::fresh_staging_dir
|
||||
// and place::finalize_install. Do not hide human paths such as demo.bak or
|
||||
// .staging-not-a-uuid, which can legitimately contain reviewed bundles.
|
||||
fn is_internal_publication_directory(name: &std::ffi::OsStr) -> bool {
|
||||
let Some(name) = name.to_str() else {
|
||||
return false;
|
||||
};
|
||||
name.strip_prefix(".staging-").is_some_and(|suffix| {
|
||||
suffix.len() == 32
|
||||
&& suffix
|
||||
.bytes()
|
||||
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
|
||||
}) || name.strip_prefix(".plugin-backup-").is_some_and(|suffix| {
|
||||
suffix.len() == 16 && suffix.bytes().all(|byte| byte.is_ascii_alphanumeric())
|
||||
})
|
||||
}
|
||||
|
||||
fn scan_root(
|
||||
root: &Path,
|
||||
scope: PluginScope,
|
||||
@@ -244,6 +261,9 @@ fn scan_root(
|
||||
entries.sort_by_key(fs::DirEntry::file_name);
|
||||
|
||||
for entry in entries {
|
||||
if is_internal_publication_directory(&entry.file_name()) {
|
||||
continue;
|
||||
}
|
||||
let plugin_root = entry.path();
|
||||
let Ok(metadata) = fs::symlink_metadata(&plugin_root) else {
|
||||
continue;
|
||||
@@ -837,3 +857,7 @@ mod tests {
|
||||
assert!(!home.join("plugins/state.json").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "discovery_internal_paths_tests.rs"]
|
||||
mod internal_publication_tests;
|
||||
@@ -0,0 +1,96 @@
|
||||
use super::*;
|
||||
|
||||
fn bundle(root: &Path, directory: &str, name: &str) -> PathBuf {
|
||||
let path = root.join(directory);
|
||||
fs::create_dir_all(&path).unwrap();
|
||||
fs::write(
|
||||
path.join("plugin.toml"),
|
||||
format!("schema_version = 1\n[plugin]\nname = {name:?}\nversion = \"1.0.0\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn config(root: &Path) -> DiscoveryConfig {
|
||||
DiscoveryConfig {
|
||||
workspace: root.join("project"),
|
||||
user_plugins_dir: root.join("plugins"),
|
||||
workspace_plugins_dir: root.join("project/.codewhale/plugins"),
|
||||
builtin_plugin_dirs: vec![],
|
||||
state_path: root.join("state.json"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_staging_copies_cannot_shadow_the_installed_incumbent() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg = config(tmp.path());
|
||||
let installed = bundle(&cfg.user_plugins_dir, "demo", "demo");
|
||||
let operation = format!(".staging-{}", uuid::Uuid::new_v4().simple());
|
||||
let staged = bundle(&cfg.user_plugins_dir, &operation, "demo");
|
||||
fs::write(staged.join("canary.txt"), b"private staged payload").unwrap();
|
||||
let registry = discover_with_config(&cfg);
|
||||
assert_eq!(registry.len(), 1);
|
||||
assert_eq!(
|
||||
registry.get("demo").unwrap().canonical_root,
|
||||
installed.canonicalize().unwrap()
|
||||
);
|
||||
assert!(
|
||||
!registry
|
||||
.diagnostics()
|
||||
.iter()
|
||||
.any(|d| d.code == "name-conflict")
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(staged.join("canary.txt")).unwrap(),
|
||||
b"private staged payload"
|
||||
);
|
||||
assert!(!cfg.state_path.exists(), "discovery remains read-only");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_backup_containers_are_hidden_but_human_bundle_paths_remain_visible() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let cfg = config(tmp.path());
|
||||
fs::create_dir_all(&cfg.user_plugins_dir).unwrap();
|
||||
let backup = tempfile::Builder::new()
|
||||
.prefix(".plugin-backup-")
|
||||
.rand_bytes(16)
|
||||
.tempdir_in(&cfg.user_plugins_dir)
|
||||
.unwrap();
|
||||
// Even a manifest accidentally written directly in private recovery
|
||||
// storage must not turn it into a discoverable public bundle.
|
||||
bundle(backup.path(), "", "private-backup");
|
||||
bundle(
|
||||
&cfg.user_plugins_dir,
|
||||
".staging-not-a-uuid",
|
||||
"human-staging",
|
||||
);
|
||||
bundle(
|
||||
&cfg.user_plugins_dir,
|
||||
".plugin-backup-short",
|
||||
"human-backup",
|
||||
);
|
||||
let dotted = cfg.user_plugins_dir.join("demo.bak");
|
||||
fs::create_dir(&dotted).unwrap();
|
||||
fs::write(
|
||||
dotted.join("plugin.json"),
|
||||
serde_json::json!({
|
||||
"$schema": crate::plugins::agent_plugin::PLUGIN_SCHEMA_URL,
|
||||
"name": "demo.bak", "version": "1.0.0"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let registry = discover_with_config(&cfg);
|
||||
assert!(registry.get("private-backup").is_none());
|
||||
assert!(registry.get("human-staging").is_some());
|
||||
assert!(registry.get("human-backup").is_some());
|
||||
assert_eq!(
|
||||
registry.get("demo.bak").unwrap().canonical_root,
|
||||
dotted.canonicalize().unwrap()
|
||||
);
|
||||
assert_eq!(registry.len(), 3);
|
||||
assert!(backup.path().join("plugin.toml").is_file());
|
||||
assert!(!cfg.state_path.exists());
|
||||
}
|
||||
@@ -23,8 +23,8 @@
|
||||
//! * The manifest `[plugin].name` must be a single path-safe segment; it
|
||||
//! becomes the destination directory name.
|
||||
//! * Overwriting a bundle that lacks the `.installed-from` marker is refused
|
||||
//! — hand-placed bundles are never clobbered. `update` swaps atomically
|
||||
//! only when the upstream bytes changed; a changed bundle automatically
|
||||
//! — hand-placed bundles are never clobbered. `update` publishes a replacement
|
||||
//! when source or installed content differs; a changed bundle automatically
|
||||
//! invalidates the hash-bound trust receipt at the next discovery.
|
||||
//! * Installed bits land **disabled and untrusted**; trust/enablement is the
|
||||
//! existing registry flow, not this module's concern.
|
||||
@@ -240,9 +240,9 @@ pub struct InstalledPlugin {
|
||||
/// Result of an [`update`] call.
|
||||
#[derive(Debug)]
|
||||
pub enum PluginUpdateResult {
|
||||
/// Upstream tarball is byte-identical to the recorded checksum; no action.
|
||||
/// Source is unchanged; local bundles also match the actual installed tree.
|
||||
NoChange,
|
||||
/// Upstream changed and the on-disk bundle was atomically replaced.
|
||||
/// Content changed and a replacement bundle was published.
|
||||
Updated(InstalledPlugin),
|
||||
/// Network policy requires approval for the download host.
|
||||
NeedsApproval(String),
|
||||
@@ -285,8 +285,9 @@ pub enum PluginInstallError {
|
||||
/// `.installed-from` last.
|
||||
///
|
||||
/// `update = false` rejects an existing destination. `update = true` (only
|
||||
/// called from [`update`]) requires the marker and replaces atomically with a
|
||||
/// backup-restore on failure.
|
||||
/// called from [`update`]) requires the marker and reserves a unique backup.
|
||||
/// A failed placement restores only into an absent destination; errors after
|
||||
/// publication retain the current path and prior backup for recovery.
|
||||
///
|
||||
/// `name_conflict` is consulted with the validated manifest name before the
|
||||
/// rename; returning `Some(message)` aborts the install. It lets the caller
|
||||
@@ -520,13 +521,12 @@ fn install_remote_bytes(
|
||||
)
|
||||
}
|
||||
|
||||
/// Re-fetch a previously installed plugin and atomically replace it if the
|
||||
/// upstream tarball changed. The replaced bundle carries new content, so the
|
||||
/// existing hash-bound trust receipt stops matching at the next discovery —
|
||||
/// re-review is forced by the registry, not by this function.
|
||||
/// Refresh a previously installed plugin from its recorded source and publish
|
||||
/// a replacement if its content differs. A changed discovery-time hash requires
|
||||
/// current review through the registry's existing hash-bound trust receipts.
|
||||
///
|
||||
/// Bundles installed from a local path cannot be re-downloaded; reinstall
|
||||
/// them with `/plugin install <path>` instead.
|
||||
/// Local bundles use the same bounded staging and backup/restore publication
|
||||
/// as installation. A failed source validation leaves the installed copy intact.
|
||||
pub async fn update(
|
||||
name: &str,
|
||||
user_plugins_dir: &Path,
|
||||
@@ -547,6 +547,59 @@ pub async fn update(
|
||||
let marker: InstalledFromMarker = serde_json::from_str(&marker_body)
|
||||
.with_context(|| format!("malformed {INSTALLED_FROM_MARKER} for {name}"))?;
|
||||
let source = PluginInstallSource::parse(&marker.spec)?;
|
||||
if let PluginInstallSource::LocalPath(path) = &source {
|
||||
let path = path.clone();
|
||||
let user_plugins_dir = user_plugins_dir.to_path_buf();
|
||||
let name = name.to_string();
|
||||
#[cfg(test)]
|
||||
let env_scope = crate::test_support::env_scope_ticket();
|
||||
return tokio::task::spawn_blocking(move || {
|
||||
#[cfg(test)]
|
||||
let _env_scope = crate::test_support::join_env_scope(env_scope);
|
||||
let staged = stage_local_copy(&path, &user_plugins_dir, max_size)?;
|
||||
let staged_path = staged.staged_path.clone();
|
||||
let result = (|| {
|
||||
if staged.name != name {
|
||||
return Err(PluginNameConflict(format!(
|
||||
"updated plugin changed name from {name} to {}; original plugin preserved",
|
||||
staged.name
|
||||
))
|
||||
.into());
|
||||
}
|
||||
// Compare the actual installed tree using the existing complete
|
||||
// bundle hash, including executable intent and provenance. A historical
|
||||
// source digest alone cannot detect an altered installed payload.
|
||||
fs::write(staged_path.join(INSTALLED_FROM_MARKER), marker_body)?;
|
||||
let (_, expected_hash) = stage::validate_staged(&staged_path)?;
|
||||
let (_, installed_hash) = stage::validate_staged(&target).context(
|
||||
"installed plugin failed validation; unchanged source was not accepted and the installed copy was preserved",
|
||||
)?;
|
||||
fs::remove_file(staged_path.join(INSTALLED_FROM_MARKER))?;
|
||||
if expected_hash == installed_hash {
|
||||
fs::remove_dir_all(&staged_path)
|
||||
.context("failed to remove unchanged plugin staging copy")?;
|
||||
return Ok(PluginUpdateResult::NoChange);
|
||||
}
|
||||
match finalize_install(staged, &marker.spec, None, "", &user_plugins_dir, true)? {
|
||||
PluginInstallOutcome::Installed(installed) => {
|
||||
Ok(PluginUpdateResult::Updated(installed))
|
||||
}
|
||||
PluginInstallOutcome::NeedsApproval(host) => {
|
||||
Ok(PluginUpdateResult::NeedsApproval(host))
|
||||
}
|
||||
PluginInstallOutcome::NetworkDenied(host) => {
|
||||
Ok(PluginUpdateResult::NetworkDenied(host))
|
||||
}
|
||||
}
|
||||
})();
|
||||
if result.is_err() {
|
||||
let _ = fs::remove_dir_all(&staged_path);
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
.context("local plugin update task failed")?;
|
||||
}
|
||||
if let PluginInstallSource::Dsh(package) = &source {
|
||||
// Re-convert the recorded package. An identical converted bundle is
|
||||
// no change; a different one replaces the installed copy, and its
|
||||
@@ -589,11 +642,7 @@ pub async fn update(
|
||||
};
|
||||
}
|
||||
let PluginInstallSource::Remote(remote) = source else {
|
||||
bail!(
|
||||
"plugin '{name}' was installed from a local path ({}) and cannot be updated from the network; \
|
||||
reinstall it with /plugin install <path>",
|
||||
marker.spec
|
||||
);
|
||||
unreachable!("local and converted sources returned above");
|
||||
};
|
||||
|
||||
let (bytes, url) = match fetch_tarball(&remote, network, max_size).await? {
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
//! Place a staged bundle at its final path, and the containment guards.
|
||||
//!
|
||||
//! [`finalize_install`] is the only writer of the destination directory: it
|
||||
//! renames the staged tree into place atomically, restores the backup on any
|
||||
//! failure, and writes `.installed-from` **last** so a partial install never
|
||||
//! leaves a stale marker. [`ensure_target_within_plugins_dir`] mirrors
|
||||
//! discovery's fail-closed rule that a bundle must resolve to a direct child
|
||||
//! of the plugins root.
|
||||
//! [`finalize_install`] reserves a private unique backup before moving an
|
||||
//! incumbent and writes `.installed-from` last. Failed publication restores
|
||||
//! only into an unoccupied destination. After publication, an error retains
|
||||
//! both the incumbent backup and the current path: without serialization we
|
||||
//! cannot prove that path still names this operation's publication, so it is
|
||||
//! never recursively deleted or overwritten as rollback.
|
||||
//! [`ensure_target_within_plugins_dir`] mirrors discovery's fail-closed rule
|
||||
//! that a bundle must resolve to a direct child of the plugins root.
|
||||
//!
|
||||
//! This is not a serialized or crash-atomic bundle swap. A retained recovery
|
||||
//! path is not proof that the old bundle was restored or remains active.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -26,7 +31,7 @@ pub(super) fn finalize_install(
|
||||
update: bool,
|
||||
) -> Result<PluginInstallOutcome> {
|
||||
let final_path = user_plugins_dir.join(&staged.name);
|
||||
let mut backup_path: Option<PathBuf> = None;
|
||||
let mut backup_container: Option<PathBuf> = None;
|
||||
if final_path.exists() {
|
||||
if !update {
|
||||
let has_marker = final_path.join(INSTALLED_FROM_MARKER).exists();
|
||||
@@ -40,34 +45,52 @@ pub(super) fn finalize_install(
|
||||
let _ = fs::remove_dir_all(&staged.staged_path);
|
||||
return Err(PluginInstallError::NotInstalledHere(staged.name).into());
|
||||
}
|
||||
let backup = user_plugins_dir.join(format!("{}.bak", staged.name));
|
||||
if backup.exists() {
|
||||
fs::remove_dir_all(&backup).ok();
|
||||
let mut builder = tempfile::Builder::new();
|
||||
builder.prefix(".plugin-backup-").rand_bytes(16);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
builder.permissions(fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
fs::rename(&final_path, &backup).with_context(|| {
|
||||
format!(
|
||||
"failed to backup existing plugin at {}",
|
||||
final_path.display()
|
||||
)
|
||||
})?;
|
||||
if let Err(error) = fs::rename(&staged.staged_path, &final_path) {
|
||||
fs::rename(&backup, &final_path).ok();
|
||||
return Err(error).context("failed to install staged plugin");
|
||||
let reserved = builder
|
||||
.tempdir_in(user_plugins_dir)
|
||||
.context("failed to reserve a private plugin backup container")?;
|
||||
crate::plugins::registry::ensure_private_plugin_state_directory(reserved.path())
|
||||
.map_err(anyhow::Error::msg)?;
|
||||
// Persist before moving any user data: TempDir cleanup must never
|
||||
// delete the incumbent during an error return or unwind.
|
||||
let container = reserved.keep();
|
||||
let backup = container.join("bundle");
|
||||
if let Err(error) = crate::plugins::builtin::publish_snapshot(&final_path, &backup) {
|
||||
if let Err(cleanup) = fs::remove_dir(&container) {
|
||||
tracing::warn!(path = %container.display(), %cleanup, "empty plugin backup container could not be removed");
|
||||
}
|
||||
return Err(error).with_context(|| {
|
||||
format!(
|
||||
"failed to backup existing plugin at {}",
|
||||
final_path.display()
|
||||
)
|
||||
});
|
||||
}
|
||||
backup_path = Some(backup);
|
||||
} else if let Err(error) = fs::rename(&staged.staged_path, &final_path) {
|
||||
let _ = fs::remove_dir_all(&staged.staged_path);
|
||||
return Err(error).context("failed to install staged plugin");
|
||||
backup_container = Some(container);
|
||||
}
|
||||
if let Err(error) = crate::plugins::builtin::publish_snapshot(&staged.staged_path, &final_path)
|
||||
{
|
||||
if let Some(container) = backup_container.as_deref() {
|
||||
return Err(restore_backup(container, &final_path, error.into()));
|
||||
}
|
||||
return Err(error)
|
||||
.context("failed to install staged plugin; existing destination preserved");
|
||||
}
|
||||
|
||||
// Discovery fail-closed rule: the installed bundle must canonicalize to a
|
||||
// direct child of the user plugins root.
|
||||
if let Err(error) = ensure_target_within_plugins_dir(&final_path, user_plugins_dir) {
|
||||
let _ = fs::remove_dir_all(&final_path);
|
||||
if let Some(backup) = backup_path.take() {
|
||||
let _ = fs::rename(&backup, &final_path);
|
||||
}
|
||||
return Err(error);
|
||||
return Err(retained_publication_error(
|
||||
error,
|
||||
&final_path,
|
||||
backup_container.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
// Write the marker last so a partial install never leaves a stale
|
||||
@@ -80,12 +103,13 @@ pub(super) fn finalize_install(
|
||||
&staged.content_hash,
|
||||
&staged.name,
|
||||
) {
|
||||
let _ = fs::remove_dir_all(&final_path);
|
||||
if let Some(backup) = backup_path.take() {
|
||||
let _ = fs::rename(&backup, &final_path);
|
||||
}
|
||||
return Err(error);
|
||||
return Err(retained_publication_error(
|
||||
error,
|
||||
&final_path,
|
||||
backup_container.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
let installed_content_hash =
|
||||
match crate::plugins::agent_plugin::resolve_manifest_path(&final_path)
|
||||
.ok_or_else(|| anyhow::anyhow!("installed plugin has no supported manifest"))
|
||||
@@ -96,15 +120,19 @@ pub(super) fn finalize_install(
|
||||
}) {
|
||||
Ok(hash) => hash,
|
||||
Err(error) => {
|
||||
let _ = fs::remove_dir_all(&final_path);
|
||||
if let Some(backup) = backup_path.take() {
|
||||
let _ = fs::rename(&backup, &final_path);
|
||||
}
|
||||
return Err(error).context("installed plugin failed post-copy validation");
|
||||
return Err(retained_publication_error(
|
||||
error.context("installed plugin failed post-copy validation"),
|
||||
&final_path,
|
||||
backup_container.as_deref(),
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Some(backup) = backup_path {
|
||||
fs::remove_dir_all(&backup).ok();
|
||||
if let Some(container) = backup_container
|
||||
&& let Err(error) = fs::remove_dir_all(&container)
|
||||
{
|
||||
// The new bundle is already installed and validated. Cleanup failure
|
||||
// does not undo it; retain the exact old-copy path in the diagnostic.
|
||||
tracing::warn!(path = %container.display(), %error, "plugin installed but prior backup cleanup failed; retained backup requires recovery cleanup");
|
||||
}
|
||||
|
||||
Ok(PluginInstallOutcome::Installed(InstalledPlugin {
|
||||
@@ -116,6 +144,54 @@ pub(super) fn finalize_install(
|
||||
}))
|
||||
}
|
||||
|
||||
/// A failed no-replace publication did not install our staged tree. Restore
|
||||
/// with the same exclusive primitive, so an intervening creator is preserved.
|
||||
pub(super) fn restore_backup(
|
||||
container: &Path,
|
||||
final_path: &Path,
|
||||
error: anyhow::Error,
|
||||
) -> anyhow::Error {
|
||||
let backup = container.join("bundle");
|
||||
match crate::plugins::builtin::publish_snapshot(&backup, final_path) {
|
||||
Ok(()) => {
|
||||
if let Err(cleanup) = fs::remove_dir(container) {
|
||||
tracing::warn!(path = %container.display(), %cleanup, "restored plugin but empty recovery container could not be removed");
|
||||
}
|
||||
error.context(format!(
|
||||
"failed to install staged plugin; previous plugin restored at {}",
|
||||
final_path.display()
|
||||
))
|
||||
}
|
||||
Err(restore) => error.context(format!(
|
||||
"failed to install staged plugin and automatic restoration failed: {restore}; \
|
||||
previous plugin retained at {}; current destination {} was not replaced; recovery required",
|
||||
backup.display(),
|
||||
final_path.display()
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn retained_publication_error(
|
||||
error: anyhow::Error,
|
||||
final_path: &Path,
|
||||
backup_container: Option<&Path>,
|
||||
) -> anyhow::Error {
|
||||
let previous = backup_container.map_or_else(
|
||||
|| "no previous plugin backup exists".to_string(),
|
||||
|container| {
|
||||
format!(
|
||||
"previous plugin retained at {}",
|
||||
container.join("bundle").display()
|
||||
)
|
||||
},
|
||||
);
|
||||
error.context(format!(
|
||||
"plugin publication failed validation; {previous}; current destination {} retained; \
|
||||
automatic restoration refused because its publication identity is not pinned; recovery required",
|
||||
final_path.display()
|
||||
))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Path guards
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -28,9 +28,9 @@ pub(super) struct StagedPlugin {
|
||||
|
||||
pub(super) fn fresh_staging_dir(user_plugins_dir: &Path) -> Result<PathBuf> {
|
||||
ensure_plugins_dir(user_plugins_dir)?;
|
||||
// A crashed stage can leave residue that discovery will surface as an
|
||||
// untrusted, disabled bundle; the next install attempt cleans it up by
|
||||
// using a fresh uuid path and never reuses the stale one.
|
||||
// Discovery excludes this reserved staging namespace. A crashed stage
|
||||
// can remain for recovery; later attempts reserve a fresh UUID path and
|
||||
// never treat the stale copy as an installed bundle or reuse its path.
|
||||
let staged_path = user_plugins_dir.join(format!(".staging-{}", uuid::Uuid::new_v4().simple()));
|
||||
fs::create_dir(&staged_path)
|
||||
.with_context(|| format!("failed to create staging dir {}", staged_path.display()))?;
|
||||
|
||||
@@ -3,6 +3,9 @@ use super::*;
|
||||
// by the verbs, so it is not in `super`'s namespace.
|
||||
use super::tarball::scan_tarball;
|
||||
|
||||
#[path = "tests/place_recovery.rs"]
|
||||
mod place_recovery;
|
||||
|
||||
fn write_bundle(root: &Path, dir: &str, name: &str) -> PathBuf {
|
||||
let bundle = root.join(dir);
|
||||
fs::create_dir_all(&bundle).unwrap();
|
||||
@@ -757,26 +760,9 @@ async fn install_enforces_the_name_conflict_hook() {
|
||||
// ── update / uninstall ────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn update_refuses_local_installs_and_missing_markers() {
|
||||
async fn update_refuses_missing_markers() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins = tmp.path().join("plugins");
|
||||
let source = write_bundle(tmp.path(), "src/demo", "demo");
|
||||
install(
|
||||
PluginInstallSource::parse(source.to_str().unwrap()).unwrap(),
|
||||
&plugins,
|
||||
DEFAULT_MAX_SIZE_BYTES,
|
||||
&allow_all(),
|
||||
false,
|
||||
&no_conflict(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(format!("{err:#}").contains("local path"), "got: {err:#}");
|
||||
|
||||
write_bundle(&plugins, "hand", "hand");
|
||||
let err = update("hand", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
|
||||
.await
|
||||
@@ -790,6 +776,108 @@ async fn update_refuses_local_installs_and_missing_markers() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn local_update_reuses_staging_and_preserves_the_installed_bundle_on_failure() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins = tmp.path().join("plugins");
|
||||
let source = write_bundle(tmp.path(), "src/demo", "demo");
|
||||
fs::write(source.join("data.txt"), b"v1").unwrap();
|
||||
install(
|
||||
PluginInstallSource::LocalPath(source.clone()),
|
||||
&plugins,
|
||||
DEFAULT_MAX_SIZE_BYTES,
|
||||
&allow_all(),
|
||||
false,
|
||||
&no_conflict(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let target = plugins.join("demo");
|
||||
let original_marker = fs::read(target.join(INSTALLED_FROM_MARKER)).unwrap();
|
||||
assert!(matches!(
|
||||
update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
|
||||
.await
|
||||
.unwrap(),
|
||||
PluginUpdateResult::NoChange
|
||||
));
|
||||
assert_eq!(
|
||||
fs::read(target.join(INSTALLED_FROM_MARKER)).unwrap(),
|
||||
original_marker
|
||||
);
|
||||
|
||||
for altered in [Some(b"altered".as_slice()), None] {
|
||||
if let Some(bytes) = altered {
|
||||
fs::write(target.join("data.txt"), bytes).unwrap();
|
||||
} else {
|
||||
fs::remove_file(target.join("data.txt")).unwrap();
|
||||
}
|
||||
assert!(matches!(
|
||||
update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
|
||||
.await
|
||||
.unwrap(),
|
||||
PluginUpdateResult::Updated(_)
|
||||
));
|
||||
assert_eq!(fs::read(target.join("data.txt")).unwrap(), b"v1");
|
||||
assert_eq!(
|
||||
fs::read(target.join(INSTALLED_FROM_MARKER)).unwrap(),
|
||||
original_marker
|
||||
);
|
||||
}
|
||||
|
||||
fs::write(source.join("data.txt"), b"v2").unwrap();
|
||||
let PluginUpdateResult::Updated(updated) =
|
||||
update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
|
||||
.await
|
||||
.unwrap()
|
||||
else {
|
||||
panic!("changed local source must update through the existing installer");
|
||||
};
|
||||
assert_eq!(fs::read(updated.path.join("data.txt")).unwrap(), b"v2");
|
||||
let updated_marker = fs::read(target.join(INSTALLED_FROM_MARKER)).unwrap();
|
||||
assert_ne!(updated_marker, original_marker);
|
||||
assert!(matches!(
|
||||
update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
|
||||
.await
|
||||
.unwrap(),
|
||||
PluginUpdateResult::NoChange
|
||||
));
|
||||
|
||||
let manifest = fs::read(source.join("plugin.toml")).unwrap();
|
||||
fs::write(
|
||||
source.join("plugin.toml"),
|
||||
b"schema_version = 1\n[plugin]\nname = \"other\"\nversion = \"2.0.0\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let error = update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(error.downcast_ref::<PluginNameConflict>().is_some());
|
||||
assert!(!plugins.join("other").exists());
|
||||
fs::write(source.join("plugin.toml"), b"malformed = [").unwrap();
|
||||
assert!(
|
||||
update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
fs::write(source.join("plugin.toml"), manifest).unwrap();
|
||||
fs::remove_dir_all(&source).unwrap();
|
||||
assert!(
|
||||
update("demo", &plugins, DEFAULT_MAX_SIZE_BYTES, &allow_all())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(fs::read(target.join("data.txt")).unwrap(), b"v2");
|
||||
assert_eq!(
|
||||
fs::read(target.join(INSTALLED_FROM_MARKER)).unwrap(),
|
||||
updated_marker
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_dir(&plugins).unwrap().count(),
|
||||
1,
|
||||
"no staging or backup residue"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn uninstall_requires_the_marker_and_removes_the_bundle() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
use super::*;
|
||||
use crate::plugins::install::place::{finalize_install, restore_backup};
|
||||
use crate::plugins::install::stage::stage_local_copy;
|
||||
|
||||
fn incumbent(plugins: &Path) -> PathBuf {
|
||||
let target = write_bundle(plugins, "demo", "demo");
|
||||
fs::write(target.join("data.txt"), b"old payload").unwrap();
|
||||
fs::write(target.join(INSTALLED_FROM_MARKER), b"old provenance").unwrap();
|
||||
target
|
||||
}
|
||||
|
||||
fn prepared_update(root: &Path, plugins: &Path) -> super::super::stage::StagedPlugin {
|
||||
let source = write_bundle(root, "source", "demo");
|
||||
fs::write(source.join("data.txt"), b"new payload").unwrap();
|
||||
stage_local_copy(&source, plugins, DEFAULT_MAX_SIZE_BYTES).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_preserves_a_legitimate_dot_bak_plugin() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins = tmp.path().join("plugins");
|
||||
let target = incumbent(&plugins);
|
||||
let other = plugins.join("demo.bak");
|
||||
fs::create_dir(&other).unwrap();
|
||||
let manifest = serde_json::json!({
|
||||
"$schema": crate::plugins::agent_plugin::PLUGIN_SCHEMA_URL,
|
||||
"name": "demo.bak", "version": "1.0.0"
|
||||
});
|
||||
fs::write(other.join("plugin.json"), manifest.to_string()).unwrap();
|
||||
fs::write(other.join("canary.txt"), b"unrelated installed plugin").unwrap();
|
||||
let before =
|
||||
crate::plugins::manifest::PluginManifest::validate_from_path(&other.join("plugin.json"))
|
||||
.unwrap()
|
||||
.content_hash;
|
||||
let staged = prepared_update(tmp.path(), &plugins);
|
||||
let outcome =
|
||||
finalize_install(staged, "path:/controlled/source", None, "", &plugins, true).unwrap();
|
||||
assert!(matches!(outcome, PluginInstallOutcome::Installed(_)));
|
||||
assert_eq!(fs::read(target.join("data.txt")).unwrap(), b"new payload");
|
||||
assert_eq!(
|
||||
fs::read(other.join("canary.txt")).unwrap(),
|
||||
b"unrelated installed plugin"
|
||||
);
|
||||
let after =
|
||||
crate::plugins::manifest::PluginManifest::validate_from_path(&other.join("plugin.json"))
|
||||
.unwrap()
|
||||
.content_hash;
|
||||
assert_eq!(
|
||||
before, after,
|
||||
"unrelated valid bundle must be byte-identical"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read_dir(&plugins).unwrap().count(),
|
||||
2,
|
||||
"successful publication leaves no backup container"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_publication_restores_the_original_bytes_and_marker() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins = tmp.path().join("plugins");
|
||||
let target = incumbent(&plugins);
|
||||
let staged = prepared_update(tmp.path(), &plugins);
|
||||
fs::remove_dir_all(&staged.staged_path).unwrap();
|
||||
let error =
|
||||
finalize_install(staged, "path:/controlled/source", None, "", &plugins, true).unwrap_err();
|
||||
assert!(format!("{error:#}").contains("previous plugin restored"));
|
||||
assert_eq!(fs::read(target.join("data.txt")).unwrap(), b"old payload");
|
||||
assert_eq!(
|
||||
fs::read(target.join(INSTALLED_FROM_MARKER)).unwrap(),
|
||||
b"old provenance"
|
||||
);
|
||||
assert_eq!(fs::read_dir(&plugins).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_restore_retains_the_old_copy_and_never_replaces_an_empty_contender() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let container = tempfile::Builder::new()
|
||||
.prefix(".plugin-backup-")
|
||||
.rand_bytes(16)
|
||||
.tempdir_in(tmp.path())
|
||||
.unwrap()
|
||||
.keep();
|
||||
let old = write_bundle(&container, "bundle", "demo");
|
||||
fs::write(old.join("data.txt"), b"recoverable old payload").unwrap();
|
||||
fs::write(old.join(INSTALLED_FROM_MARKER), b"recoverable old marker").unwrap();
|
||||
let contender = tmp.path().join("demo");
|
||||
fs::create_dir(&contender).unwrap();
|
||||
let error = restore_backup(
|
||||
&container,
|
||||
&contender,
|
||||
anyhow::anyhow!("publication refused"),
|
||||
);
|
||||
let message = format!("{error:#}");
|
||||
assert!(message.contains("automatic restoration failed"));
|
||||
assert!(message.contains(&old.display().to_string()));
|
||||
assert!(message.contains("current destination") && message.contains("was not replaced"));
|
||||
assert_eq!(
|
||||
fs::read(old.join("data.txt")).unwrap(),
|
||||
b"recoverable old payload"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(old.join(INSTALLED_FROM_MARKER)).unwrap(),
|
||||
b"recoverable old marker"
|
||||
);
|
||||
assert!(contender.is_dir());
|
||||
assert_eq!(
|
||||
fs::read_dir(&contender).unwrap().count(),
|
||||
0,
|
||||
"atomic no-replace must preserve even an empty contender directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_failure_retains_prior_and_current_publications_for_recovery() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let plugins = tmp.path().join("plugins");
|
||||
let target = incumbent(&plugins);
|
||||
let staged = prepared_update(tmp.path(), &plugins);
|
||||
// A deterministic real filesystem refusal after rename: a directory
|
||||
// cannot be overwritten by the provenance marker's fs::write.
|
||||
fs::create_dir(staged.staged_path.join(INSTALLED_FROM_MARKER)).unwrap();
|
||||
let error =
|
||||
finalize_install(staged, "path:/controlled/source", None, "", &plugins, true).unwrap_err();
|
||||
let message = format!("{error:#}");
|
||||
assert!(message.contains("automatic restoration refused"));
|
||||
assert!(message.contains("publication identity is not pinned"));
|
||||
assert!(!message.contains("previous plugin restored"));
|
||||
assert_eq!(fs::read(target.join("data.txt")).unwrap(), b"new payload");
|
||||
assert!(target.join(INSTALLED_FROM_MARKER).is_dir());
|
||||
let containers = fs::read_dir(&plugins)
|
||||
.unwrap()
|
||||
.map(|entry| entry.unwrap().path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with(".plugin-backup-")
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(containers.len(), 1);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
assert_eq!(
|
||||
fs::metadata(&containers[0]).unwrap().permissions().mode() & 0o077,
|
||||
0,
|
||||
"recovery container must be owner-only at creation"
|
||||
);
|
||||
}
|
||||
let retained = containers[0].join("bundle");
|
||||
assert!(message.contains(&retained.display().to_string()));
|
||||
assert_eq!(fs::read(retained.join("data.txt")).unwrap(), b"old payload");
|
||||
assert_eq!(
|
||||
fs::read(retained.join(INSTALLED_FROM_MARKER)).unwrap(),
|
||||
b"old provenance"
|
||||
);
|
||||
}
|
||||
@@ -30,7 +30,7 @@ pub enum PluginMutationRequest {
|
||||
source: PluginInstallSource,
|
||||
expected_content_hash: String,
|
||||
},
|
||||
/// Re-download a previously installed bundle by name or id. A changed
|
||||
/// Refresh a previously installed bundle by name or id. A changed
|
||||
/// bundle automatically invalidates its trust receipt at next discovery.
|
||||
Update { selector: String },
|
||||
/// Delete an installed bundle and prune its persisted state entry.
|
||||
|
||||
+136
-13
@@ -1588,7 +1588,15 @@ fn open_runtime_threads_for_server(
|
||||
SharedRuntimeThreadManager,
|
||||
crate::tools::large_output_router::WorkshopConfig,
|
||||
)> {
|
||||
open_runtime_threads_for_host(config, workspace, manager_config, plugin_registry, false)
|
||||
open_runtime_threads_for_host(
|
||||
config,
|
||||
workspace,
|
||||
manager_config,
|
||||
plugin_registry,
|
||||
false,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn open_runtime_threads_for_host(
|
||||
@@ -1597,6 +1605,8 @@ pub(crate) fn open_runtime_threads_for_host(
|
||||
manager_config: RuntimeThreadManagerConfig,
|
||||
plugin_registry: Arc<crate::plugins::PluginRegistry>,
|
||||
acp: bool,
|
||||
preheld_lock: Option<crate::runtime_threads::RuntimeProcessOwnerLock>,
|
||||
binding: Option<&crate::runtime_threads::RuntimeStoreBinding>,
|
||||
) -> Result<(
|
||||
SharedRuntimeThreadManager,
|
||||
crate::tools::large_output_router::WorkshopConfig,
|
||||
@@ -1606,16 +1616,20 @@ pub(crate) fn open_runtime_threads_for_host(
|
||||
// thread manager can spawn any of those engines, matching interactive and
|
||||
// headless exec startup.
|
||||
let workshop_activation = install_runtime_server_workshop_budgets(config);
|
||||
let manager = Arc::new(if acp {
|
||||
RuntimeThreadManager::open_acp(config.clone(), workspace, manager_config, plugin_registry)
|
||||
let profile = if acp {
|
||||
crate::core::engine::EngineHostProfile::Acp
|
||||
} else {
|
||||
RuntimeThreadManager::open_with_plugin_registry(
|
||||
config.clone(),
|
||||
workspace,
|
||||
manager_config,
|
||||
plugin_registry,
|
||||
)
|
||||
}?);
|
||||
crate::core::engine::EngineHostProfile::Normal
|
||||
};
|
||||
let manager = Arc::new(RuntimeThreadManager::open_host(
|
||||
config.clone(),
|
||||
workspace,
|
||||
manager_config,
|
||||
plugin_registry,
|
||||
profile,
|
||||
preheld_lock,
|
||||
binding,
|
||||
)?);
|
||||
// Publish the same exact endpoint-scoped catalog as interactive startup
|
||||
// before the server admits turns. A cached model list alone does not make
|
||||
// its capabilities available to route resolution.
|
||||
@@ -1623,6 +1637,79 @@ pub(crate) fn open_runtime_threads_for_host(
|
||||
Ok((manager, workshop_activation))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
enum RuntimeHostAdmission {
|
||||
Attached(Box<codewhale_app_server::daemon_client::OwnerClient>),
|
||||
Bootstrap {
|
||||
lock: crate::runtime_threads::RuntimeProcessOwnerLock,
|
||||
binding: Option<crate::runtime_threads::RuntimeStoreBinding>,
|
||||
recovery: Option<Arc<codewhale_app_server::daemon_client::UnavailablePublication>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Observe first, then hold the existing store lease through all recovery.
|
||||
/// Only pre-write publication changes or lease contention can re-observe.
|
||||
#[cfg(unix)]
|
||||
async fn admit_runtime_host(
|
||||
config_path: Option<PathBuf>,
|
||||
selected_socket: Option<PathBuf>,
|
||||
selected_store: PathBuf,
|
||||
) -> Result<RuntimeHostAdmission> {
|
||||
use codewhale_app_server::daemon_client::{HostOwnerProbe, PublicationChangedBeforeAttach};
|
||||
use codewhale_app_server::daemon_socket::owner_work;
|
||||
tokio::time::timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
let probe = match codewhale_app_server::daemon_client::probe_owner_for_host_startup(
|
||||
config_path.clone(), selected_socket.clone(),
|
||||
).await {
|
||||
Err(error) if error.is::<PublicationChangedBeforeAttach>() => {
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
continue;
|
||||
}
|
||||
result => result?,
|
||||
};
|
||||
let (binding, recovery) = match probe {
|
||||
HostOwnerProbe::Attached(client) => return Ok(RuntimeHostAdmission::Attached(client)),
|
||||
HostOwnerProbe::Absent => (None, None),
|
||||
HostOwnerProbe::Unavailable(publication) => {
|
||||
let binding = validate_selected_owner_receipt(publication.receipt().clone(), selected_store.clone()).await?;
|
||||
(Some(binding), Some(publication))
|
||||
}
|
||||
};
|
||||
let store = selected_store.clone();
|
||||
let existing = binding.clone();
|
||||
let create = recovery.is_none();
|
||||
let lock = owner_work(move || {
|
||||
if let Some(binding) = existing.as_ref() { binding.validate_existing_store()?; }
|
||||
crate::runtime_threads::RuntimeProcessOwnerLock::try_acquire_for_host(&store, create)
|
||||
}).await?;
|
||||
let Some(lock) = lock else {
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
continue;
|
||||
};
|
||||
let store = selected_store.clone();
|
||||
let existing = binding.clone();
|
||||
let lock = owner_work(move || {
|
||||
lock.validate_at_root(&store)?;
|
||||
if let Some(binding) = existing.as_ref() { binding.validate_existing_store()?; }
|
||||
Ok(lock)
|
||||
}).await?;
|
||||
let current = match recovery.as_ref() {
|
||||
Some(publication) => publication.revalidate().await?,
|
||||
None => codewhale_app_server::daemon_client::publication_is_absent(
|
||||
config_path.clone(), selected_socket.clone(),
|
||||
).await?,
|
||||
};
|
||||
if !current {
|
||||
drop(lock);
|
||||
tokio::time::sleep(Duration::from_millis(25)).await;
|
||||
continue;
|
||||
}
|
||||
return Ok(RuntimeHostAdmission::Bootstrap { lock, binding, recovery });
|
||||
}
|
||||
}).await.context("Runtime host admission deadline expired; attachment outcome may be uncertain and is not replayed")?
|
||||
}
|
||||
|
||||
/// Prefix of the first line the Runtime prints once it holds its listener.
|
||||
pub const RUNTIME_LISTENING_PREFIX: &str = "Runtime API listening on http://";
|
||||
|
||||
@@ -1646,12 +1733,34 @@ pub async fn run_http_server(
|
||||
Some(task_default_model.clone()),
|
||||
Some(options.workers),
|
||||
);
|
||||
#[cfg(any(unix, windows))]
|
||||
#[cfg(unix)]
|
||||
let admission = admit_runtime_host(
|
||||
options.config_path.clone(),
|
||||
selected_control_socket(&options),
|
||||
RuntimeThreadManagerConfig::from_task_data_dir(task_cfg.data_dir.clone()).data_dir,
|
||||
)
|
||||
.await?;
|
||||
#[cfg(unix)]
|
||||
let (published, preheld_lock, existing_binding, recovery) = match admission {
|
||||
RuntimeHostAdmission::Attached(client) => (Some(*client), None, None, None),
|
||||
RuntimeHostAdmission::Bootstrap {
|
||||
lock,
|
||||
binding,
|
||||
recovery,
|
||||
} => (None, Some(lock), binding, recovery),
|
||||
};
|
||||
#[cfg(windows)]
|
||||
let published = codewhale_app_server::daemon_client::connect_if_published(
|
||||
options.config_path.clone(),
|
||||
selected_control_socket(&options),
|
||||
)
|
||||
.await?;
|
||||
#[cfg(not(unix))]
|
||||
let preheld_lock = None;
|
||||
#[cfg(not(unix))]
|
||||
let existing_binding: Option<crate::runtime_threads::RuntimeStoreBinding> = None;
|
||||
#[cfg(windows)]
|
||||
let recovery = None;
|
||||
#[cfg(any(unix, windows))]
|
||||
if let Some(control) = published {
|
||||
let selected_store =
|
||||
@@ -1761,6 +1870,8 @@ pub async fn run_http_server(
|
||||
manager_config,
|
||||
plugin_discovery.registry_for_workspace(&workspace),
|
||||
acp_selected,
|
||||
preheld_lock,
|
||||
existing_binding.as_ref(),
|
||||
)?;
|
||||
let sessions_dir = runtime_threads.sessions_dir().to_path_buf();
|
||||
let task_manager =
|
||||
@@ -1865,6 +1976,7 @@ pub async fn run_http_server(
|
||||
},
|
||||
options.workers,
|
||||
resolved_auth.generated,
|
||||
recovery,
|
||||
)
|
||||
.await?;
|
||||
let listener_workspace = state.workspace.clone();
|
||||
@@ -2003,7 +2115,16 @@ pub(crate) async fn validate_selected_owner(
|
||||
client: &codewhale_app_server::daemon_client::OwnerClient,
|
||||
selected_store: PathBuf,
|
||||
) -> Result<()> {
|
||||
let receipt = client.receipt().clone();
|
||||
validate_selected_owner_receipt(client.receipt().clone(), selected_store)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
#[cfg(any(unix, windows))]
|
||||
async fn validate_selected_owner_receipt(
|
||||
receipt: codewhale_protocol::RuntimeOwnerReceipt,
|
||||
selected_store: PathBuf,
|
||||
) -> Result<crate::runtime_threads::RuntimeStoreBinding> {
|
||||
codewhale_app_server::daemon_socket::owner_work(move || {
|
||||
let selected = crate::runtime_threads::RuntimeStoreBinding::for_store_dir(&selected_store)?;
|
||||
selected.validate_existing_store()?;
|
||||
@@ -2013,7 +2134,7 @@ pub(crate) async fn validate_selected_owner(
|
||||
&& selected.execution_scope == receipt.execution_scope,
|
||||
"authenticated Runtime owner belongs to another selected store; refusing attachment"
|
||||
);
|
||||
Ok(())
|
||||
Ok(selected)
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -2025,6 +2146,7 @@ async fn bind_captured_runtime_frontends(
|
||||
model: String,
|
||||
worker_setting: usize,
|
||||
generated_auth: bool,
|
||||
recovery: Option<Arc<codewhale_app_server::daemon_client::UnavailablePublication>>,
|
||||
) -> Result<(
|
||||
codewhale_app_server::daemon_socket::DaemonSocket,
|
||||
codewhale_app_server::AppState,
|
||||
@@ -2079,6 +2201,7 @@ async fn bind_captured_runtime_frontends(
|
||||
CapturedRuntimeFrontend::capture(state.clone(), model, worker_setting, generated_auth)
|
||||
.await?,
|
||||
),
|
||||
recovery,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -459,8 +459,8 @@ async fn run_plugin_mutation(
|
||||
to review its capabilities, then trust and enable it.",
|
||||
),
|
||||
PluginMutationOutcome::Updated => Some(
|
||||
"Content changed; the previous trust receipt no longer matches. \
|
||||
Review and trust it again before enabling.",
|
||||
"Installed content refreshed. Review its current capabilities \
|
||||
and trust it if needed before enabling.",
|
||||
),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
@@ -16,6 +16,8 @@ use uuid::Uuid;
|
||||
|
||||
mod command_catalog;
|
||||
mod headless_catalog;
|
||||
#[cfg(unix)]
|
||||
mod owner_bootstrap;
|
||||
mod profile_constitution;
|
||||
#[cfg(any(unix, windows))]
|
||||
mod runtime_store_convergence;
|
||||
@@ -1355,7 +1357,7 @@ async fn build_test_server(
|
||||
let model = runtime_request_model(&state.config.read(), None)
|
||||
.map_err(|error| anyhow::anyhow!(error.message))?;
|
||||
let (owner, compatibility) =
|
||||
bind_captured_runtime_frontends(&state, Some(socket), model, 1, false).await?;
|
||||
bind_captured_runtime_frontends(&state, Some(socket), model, 1, false, None).await?;
|
||||
(Some(owner), Some(compatibility))
|
||||
} else {
|
||||
(None, None)
|
||||
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Actual manager/store/lease bootstrap, without any provider turn. Transport
|
||||
//! fixtures do not substitute for the separate rebuilt two-process proof.
|
||||
use super::*;
|
||||
use crate::runtime_threads::{RuntimeProcessOwnerLock, RuntimeStoreBinding, ThreadListFilter};
|
||||
use codewhale_config::private_directory::PrivateDirectory;
|
||||
use codewhale_protocol::RuntimeOwnerReceipt;
|
||||
|
||||
struct OwnedChild(std::process::Child);
|
||||
impl Drop for OwnedChild {
|
||||
fn drop(&mut self) {
|
||||
if self.0.try_wait().ok().flatten().is_none() {
|
||||
let _ = self.0.kill();
|
||||
}
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
struct Rig {
|
||||
_temporary: tempfile::TempDir,
|
||||
root: PathBuf,
|
||||
store: PathBuf,
|
||||
socket: PathBuf,
|
||||
config_path: PathBuf,
|
||||
config: Config,
|
||||
}
|
||||
impl Rig {
|
||||
fn new() -> Result<Self> {
|
||||
let temporary = tempfile::Builder::new()
|
||||
.prefix("cw-ob-")
|
||||
.tempdir_in("/tmp")?;
|
||||
let root = temporary.path().canonicalize()?;
|
||||
let config_path = root.join("config.toml");
|
||||
fs::write(&config_path, "")?;
|
||||
fs::create_dir(root.join("workspace"))?;
|
||||
let config = Config::default().with_legacy_root(
|
||||
Some("bootstrap-no-provider-fixture".into()),
|
||||
Some("http://127.0.0.1:1/v1".into()),
|
||||
);
|
||||
Ok(Self {
|
||||
store: root.join("runtime"),
|
||||
socket: root.join("run/daemon.sock"),
|
||||
_temporary: temporary,
|
||||
root,
|
||||
config_path,
|
||||
config,
|
||||
})
|
||||
}
|
||||
fn manager(
|
||||
&self,
|
||||
lock: Option<RuntimeProcessOwnerLock>,
|
||||
binding: Option<&RuntimeStoreBinding>,
|
||||
) -> Result<SharedRuntimeThreadManager> {
|
||||
let (manager, _) = open_runtime_threads_for_host(
|
||||
&self.config,
|
||||
self.root.join("workspace"),
|
||||
RuntimeThreadManagerConfig {
|
||||
data_dir: self.store.clone(),
|
||||
task_data_dir: self.root.join("tasks"),
|
||||
sessions_dir: Some(self.root.join("sessions")),
|
||||
max_active_threads: 8,
|
||||
},
|
||||
Arc::new(crate::plugins::PluginRegistry::new()),
|
||||
false,
|
||||
lock,
|
||||
binding,
|
||||
)?;
|
||||
Ok(manager)
|
||||
}
|
||||
async fn receipt(&self, manager: &SharedRuntimeThreadManager) -> Result<RuntimeOwnerReceipt> {
|
||||
let (binding, generation) = manager.capture_control_owner()?;
|
||||
Ok(RuntimeOwnerReceipt {
|
||||
version: 1,
|
||||
data_dir: binding.data_dir,
|
||||
execution_scope: binding.execution_scope,
|
||||
lease_generation: generation,
|
||||
pid: std::process::id(),
|
||||
process_start: codewhale_app_server::daemon_socket::capture_process_start(
|
||||
std::process::id(),
|
||||
)
|
||||
.await?,
|
||||
principal: PrivateDirectory::current_user_id().to_string(),
|
||||
socket_path: self.socket.clone(),
|
||||
config_path: Some(self.config_path.clone()),
|
||||
})
|
||||
}
|
||||
fn publish(&self, owner: &RuntimeOwnerReceipt) -> Result<()> {
|
||||
let parent = PrivateDirectory::admit(self.socket.parent().unwrap())?;
|
||||
let listener = std::os::unix::net::UnixListener::bind(&self.socket)?;
|
||||
let identity = parent
|
||||
.socket_identity("daemon.sock")?
|
||||
.context("owned socket")?;
|
||||
parent.protect_socket("daemon.sock", identity)?;
|
||||
drop(listener);
|
||||
parent.write_owned_file("daemon.sock.owner.json", &serde_json::to_vec(owner)?, false)?;
|
||||
Ok(())
|
||||
}
|
||||
async fn dead_publication(&self) -> Result<(RuntimeOwnerReceipt, String, Value)> {
|
||||
let manager = self.manager(None, None)?;
|
||||
let draft = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
allow_shell: Some(false),
|
||||
system_prompt: Some("Preserve this unrelated saved draft".into()),
|
||||
..CreateThreadRequest::default()
|
||||
})
|
||||
.await?;
|
||||
let detail = serde_json::to_value(manager.get_thread_detail(&draft.id).await?)?;
|
||||
let mut owner = self.receipt(&manager).await?;
|
||||
manager.shutdown_and_wait().await?;
|
||||
drop(manager);
|
||||
let mut child = OwnedChild(
|
||||
std::process::Command::new("/bin/sleep")
|
||||
.arg("60")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()?,
|
||||
);
|
||||
owner.pid = child.0.id();
|
||||
owner.process_start =
|
||||
codewhale_app_server::daemon_socket::capture_process_start(owner.pid).await?;
|
||||
child.0.kill()?;
|
||||
child.0.wait()?;
|
||||
self.publish(&owner)?;
|
||||
Ok((owner, draft.id, detail))
|
||||
}
|
||||
async fn admit(&self) -> Result<RuntimeHostAdmission> {
|
||||
admit_runtime_host(
|
||||
Some(self.config_path.clone()),
|
||||
Some(self.socket.clone()),
|
||||
self.store.clone(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn preheld_bootstrap_lease_stays_with_real_manager_until_drop() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let rig = Rig::new()?;
|
||||
let RuntimeHostAdmission::Bootstrap {
|
||||
lock,
|
||||
binding,
|
||||
recovery,
|
||||
} = rig.admit().await?
|
||||
else {
|
||||
bail!("fresh host must bootstrap")
|
||||
};
|
||||
assert!(binding.is_none() && recovery.is_none());
|
||||
assert!(RuntimeProcessOwnerLock::try_acquire_for_host(&rig.store, false)?.is_none());
|
||||
let manager = rig.manager(Some(lock), binding.as_ref())?;
|
||||
assert!(RuntimeProcessOwnerLock::try_acquire_for_host(&rig.store, false)?.is_none());
|
||||
let (owner, generation) = manager.capture_control_owner()?;
|
||||
assert_eq!(owner.data_dir, rig.store);
|
||||
assert!(!generation.is_empty());
|
||||
manager.shutdown_and_wait().await?;
|
||||
assert!(
|
||||
RuntimeProcessOwnerLock::try_acquire_for_host(&rig.store, false)?.is_none(),
|
||||
"drain alone must not release retained ownership"
|
||||
);
|
||||
drop(manager);
|
||||
assert!(RuntimeProcessOwnerLock::try_acquire_for_host(&rig.store, false)?.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_crash_reentries_converge_on_one_owner_and_preserve_saved_draft() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let rig = Rig::new()?;
|
||||
let (old, draft, detail) = rig.dead_publication().await?;
|
||||
let gate = Arc::new(tokio::sync::Barrier::new(3));
|
||||
let start = || {
|
||||
let gate = gate.clone();
|
||||
let config = rig.config_path.clone();
|
||||
let socket = rig.socket.clone();
|
||||
let store = rig.store.clone();
|
||||
tokio::spawn(async move {
|
||||
gate.wait().await;
|
||||
admit_runtime_host(Some(config), Some(socket), store).await
|
||||
})
|
||||
};
|
||||
let mut first = start();
|
||||
let mut second = start();
|
||||
gate.wait().await;
|
||||
let (admission, loser) = tokio::select! {
|
||||
result = &mut first => (result??, second),
|
||||
result = &mut second => (result??, first),
|
||||
};
|
||||
let RuntimeHostAdmission::Bootstrap {
|
||||
lock,
|
||||
binding,
|
||||
recovery,
|
||||
} = admission
|
||||
else {
|
||||
bail!("one crash reentry must win the existing lease")
|
||||
};
|
||||
let recovery = recovery.context("exact stale publication")?;
|
||||
assert_eq!(recovery.receipt(), &old);
|
||||
assert!(RuntimeProcessOwnerLock::try_acquire_for_host(&rig.store, false)?.is_none());
|
||||
let manager = rig.manager(Some(lock), binding.as_ref())?;
|
||||
let owner = rig.receipt(&manager).await?;
|
||||
assert_ne!(owner.lease_generation, old.lease_generation);
|
||||
assert_eq!(owner.execution_scope, old.execution_scope);
|
||||
let (daemon, _) = codewhale_app_server::bind_runtime_frontends(
|
||||
Some(rig.config_path.clone()),
|
||||
None,
|
||||
owner.clone(),
|
||||
codewhale_app_server::RuntimeOwnerRouting {
|
||||
endpoint: "127.0.0.1:1".parse()?,
|
||||
workspace: None,
|
||||
workers: None,
|
||||
mobile: false,
|
||||
web: false,
|
||||
acp: false,
|
||||
acp_only: false,
|
||||
},
|
||||
None,
|
||||
Some(recovery),
|
||||
)
|
||||
.await?;
|
||||
let stop = daemon.shutdown_handle();
|
||||
let serving = tokio::spawn(daemon.serve());
|
||||
let RuntimeHostAdmission::Attached(client) = loser.await?? else {
|
||||
bail!("loser must attach to the winner, never open another manager")
|
||||
};
|
||||
assert_eq!(client.receipt(), &owner);
|
||||
validate_selected_owner(&client, rig.store.clone()).await?;
|
||||
assert_eq!(
|
||||
serde_json::to_value(manager.get_thread_detail(&draft).await?)?,
|
||||
detail
|
||||
);
|
||||
assert_eq!(
|
||||
manager
|
||||
.list_threads(ThreadListFilter::IncludeArchived, None)
|
||||
.await?
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
drop(client);
|
||||
stop.trigger();
|
||||
serving.await??;
|
||||
manager.shutdown_and_wait().await?;
|
||||
drop(manager);
|
||||
assert!(RuntimeProcessOwnerLock::try_acquire_for_host(&rig.store, false)?.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn dead_publication_with_missing_existing_lease_refuses_without_recreation() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let rig = Rig::new()?;
|
||||
let (owner, _, _) = rig.dead_publication().await?;
|
||||
let lease = rig.store.join("runtime-process.owner.lock");
|
||||
fs::remove_file(&lease)?;
|
||||
assert!(rig.admit().await.is_err());
|
||||
assert!(
|
||||
!lease.exists(),
|
||||
"dead publication cannot manufacture a replacement lease"
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<RuntimeOwnerReceipt>(&fs::read(
|
||||
rig.socket.with_file_name("daemon.sock.owner.json")
|
||||
)?)?,
|
||||
owner
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn owner_bootstrap_attach_response_loss_never_falls_back_or_changes_store() -> Result<()> {
|
||||
use tokio::io::AsyncBufReadExt as _;
|
||||
let _env = lock_test_env();
|
||||
let rig = Rig::new()?;
|
||||
let manager = rig.manager(None, None)?;
|
||||
let owner = rig.receipt(&manager).await?;
|
||||
manager.shutdown_and_wait().await?;
|
||||
drop(manager);
|
||||
let store_owner = fs::read(rig.store.join("owner.json"))?;
|
||||
let parent = PrivateDirectory::admit(rig.socket.parent().unwrap())?;
|
||||
let listener = tokio::net::UnixListener::bind(&rig.socket)?;
|
||||
let identity = parent.socket_identity("daemon.sock")?.context("socket")?;
|
||||
parent.protect_socket("daemon.sock", identity)?;
|
||||
let bytes = serde_json::to_vec(&owner)?;
|
||||
parent.write_owned_file("daemon.sock.owner.json", &bytes, false)?;
|
||||
let peer = tokio::spawn(async move {
|
||||
let (stream, _) = listener.accept().await?;
|
||||
let mut line = String::new();
|
||||
tokio::io::BufReader::new(stream)
|
||||
.read_line(&mut line)
|
||||
.await?;
|
||||
let request: Value = serde_json::from_str(&line)?;
|
||||
anyhow::ensure!(
|
||||
request["method"] == "daemon/attach",
|
||||
"one real attach write"
|
||||
);
|
||||
// Simulate lost acknowledgement after the valid peer saw attach.
|
||||
Ok::<_, anyhow::Error>(())
|
||||
});
|
||||
let error = rig.admit().await.err().context("lost attach must refuse")?;
|
||||
assert!(error.to_string().contains("closed before attach"));
|
||||
peer.await??;
|
||||
assert_eq!(fs::read(rig.store.join("owner.json"))?, store_owner);
|
||||
assert_eq!(
|
||||
parent
|
||||
.read_private_receipt("daemon.sock.owner.json", 16384)?
|
||||
.context("original receipt")?
|
||||
.0,
|
||||
bytes
|
||||
);
|
||||
assert_eq!(parent.socket_identity("daemon.sock")?, Some(identity));
|
||||
assert!(
|
||||
RuntimeProcessOwnerLock::try_acquire_for_host(&rig.store, false)?.is_some(),
|
||||
"no owner manager was opened after attach uncertainty"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_publication_cannot_select_another_store_or_execution_scope() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
for wrong_store in [false, true] {
|
||||
let rig = Rig::new()?;
|
||||
let (mut owner, _, _) = rig.dead_publication().await?;
|
||||
if wrong_store {
|
||||
owner.data_dir = rig.root.join("other-store");
|
||||
} else {
|
||||
owner.execution_scope.push_str("-wrong");
|
||||
}
|
||||
fs::write(
|
||||
rig.socket.with_file_name("daemon.sock.owner.json"),
|
||||
serde_json::to_vec(&owner)?,
|
||||
)?;
|
||||
assert!(rig.admit().await.is_err());
|
||||
assert!(!rig.root.join("other-store").exists());
|
||||
assert_eq!(
|
||||
serde_json::from_slice::<RuntimeOwnerReceipt>(&fs::read(
|
||||
rig.socket.with_file_name("daemon.sock.owner.json")
|
||||
)?)?,
|
||||
owner
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn moved_preheld_lease_refuses_before_store_recovery() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let rig = Rig::new()?;
|
||||
let RuntimeHostAdmission::Bootstrap { lock, .. } = rig.admit().await? else {
|
||||
bail!("fresh bootstrap")
|
||||
};
|
||||
let lease = rig.store.join("runtime-process.owner.lock");
|
||||
fs::rename(&lease, rig.store.join("moved.owner.lock"))?;
|
||||
fs::write(&lease, b"")?;
|
||||
assert!(rig.manager(Some(lock), None).is_err());
|
||||
assert!(
|
||||
!rig.store.join("owner.json").exists(),
|
||||
"invalid held lease must not recover/mint the store"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn busy_unpublished_owner_has_bounded_refusal_without_store_recovery() -> Result<()> {
|
||||
let _env = lock_test_env();
|
||||
let rig = Rig::new()?;
|
||||
let held = RuntimeProcessOwnerLock::try_acquire_for_host(&rig.store, true)?
|
||||
.context("first owner lease")?;
|
||||
let started = tokio::time::Instant::now();
|
||||
let error = rig
|
||||
.admit()
|
||||
.await
|
||||
.err()
|
||||
.context("busy host must remain bounded")?;
|
||||
assert!(error.to_string().contains("deadline expired"));
|
||||
assert!(started.elapsed() < Duration::from_secs(8));
|
||||
assert!(!rig.store.join("owner.json").exists());
|
||||
assert!(!rig.socket.with_file_name("daemon.sock.owner.json").exists());
|
||||
drop(held);
|
||||
Ok(())
|
||||
}
|
||||
@@ -6635,6 +6635,23 @@ impl RuntimeProcessOwnerLock {
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Host admission reuses this exact store lease, never a PID-file lock.
|
||||
pub(crate) fn try_acquire_for_host(root: &Path, create: bool) -> Result<Option<Self>> {
|
||||
if !create {
|
||||
checked_existing_runtime_store_dir(root)?;
|
||||
}
|
||||
Self::try_acquire_file(&root.join(RUNTIME_PROCESS_OWNER_LOCK_FILE), create)
|
||||
}
|
||||
|
||||
pub(crate) fn validate_at_root(&self, root: &Path) -> Result<()> {
|
||||
let root = checked_runtime_store_root(root.to_path_buf())?;
|
||||
anyhow::ensure!(
|
||||
owner_lock_is_at_path(&self._file, &root.join(RUNTIME_PROCESS_OWNER_LOCK_FILE))?,
|
||||
"held Runtime owner lease changed before host recovery"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn acquire(root: &Path) -> Result<Self> {
|
||||
let root = checked_runtime_store_root(root.to_path_buf())?;
|
||||
let path = root.join(RUNTIME_PROCESS_OWNER_LOCK_FILE);
|
||||
@@ -7408,6 +7425,7 @@ impl RuntimeThreadManager {
|
||||
None,
|
||||
None,
|
||||
crate::core::engine::EngineHostProfile::Normal,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7424,6 +7442,7 @@ impl RuntimeThreadManager {
|
||||
Some(plugin_registry),
|
||||
None,
|
||||
crate::core::engine::EngineHostProfile::Normal,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7457,6 +7476,7 @@ impl RuntimeThreadManager {
|
||||
Some(plugin_registry),
|
||||
None,
|
||||
crate::core::engine::EngineHostProfile::Normal,
|
||||
None,
|
||||
);
|
||||
}
|
||||
binding.validate_existing_store()?;
|
||||
@@ -7469,6 +7489,7 @@ impl RuntimeThreadManager {
|
||||
Some(plugin_registry),
|
||||
binding,
|
||||
crate::core::engine::EngineHostProfile::Normal,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7496,6 +7517,7 @@ impl RuntimeThreadManager {
|
||||
Some(plugin_registry),
|
||||
Some(binding),
|
||||
crate::core::engine::EngineHostProfile::Normal,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7521,19 +7543,23 @@ impl RuntimeThreadManager {
|
||||
Self::open_existing_session(config, workspace, manager_cfg, plugin_registry, &binding)
|
||||
}
|
||||
|
||||
pub(crate) fn open_acp(
|
||||
pub(crate) fn open_host(
|
||||
config: Config,
|
||||
workspace: PathBuf,
|
||||
manager_cfg: RuntimeThreadManagerConfig,
|
||||
plugins: Arc<crate::plugins::PluginRegistry>,
|
||||
host_profile: crate::core::engine::EngineHostProfile,
|
||||
preheld_lock: Option<RuntimeProcessOwnerLock>,
|
||||
binding: Option<&RuntimeStoreBinding>,
|
||||
) -> Result<Self> {
|
||||
Self::open_inner(
|
||||
config,
|
||||
workspace,
|
||||
manager_cfg,
|
||||
Some(plugins),
|
||||
None,
|
||||
crate::core::engine::EngineHostProfile::Acp,
|
||||
binding,
|
||||
host_profile,
|
||||
preheld_lock,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7544,6 +7570,7 @@ impl RuntimeThreadManager {
|
||||
plugin_registry: Option<Arc<crate::plugins::PluginRegistry>>,
|
||||
binding: Option<&RuntimeStoreBinding>,
|
||||
host_profile: crate::core::engine::EngineHostProfile,
|
||||
preheld_lock: Option<RuntimeProcessOwnerLock>,
|
||||
) -> Result<Self> {
|
||||
// A public RuntimeThreadManager owns independent native threads. They
|
||||
// may run concurrently with the interactive TUI because their events
|
||||
@@ -7560,9 +7587,18 @@ impl RuntimeThreadManager {
|
||||
.sessions_dir()
|
||||
.to_path_buf(),
|
||||
);
|
||||
let process_owner_lock = Arc::new(RuntimeProcessOwnerLock::acquire(&manager_cfg.data_dir)?);
|
||||
let process_owner_lock = match preheld_lock {
|
||||
Some(lock) => lock,
|
||||
None => RuntimeProcessOwnerLock::acquire(&manager_cfg.data_dir)?,
|
||||
};
|
||||
process_owner_lock.validate_at_root(&manager_cfg.data_dir)?;
|
||||
let process_owner_lock = Arc::new(process_owner_lock);
|
||||
// Recheck under the exclusive host lock, before store recovery can write.
|
||||
if let Some(binding) = binding {
|
||||
anyhow::ensure!(
|
||||
checked_runtime_store_root(manager_cfg.data_dir.clone())? == binding.data_dir,
|
||||
"captured Runtime store differs from the held host lease"
|
||||
);
|
||||
binding.validate_existing_store()?;
|
||||
}
|
||||
let store = RuntimeThreadStore::open(manager_cfg.data_dir.clone())?;
|
||||
@@ -17308,6 +17344,7 @@ impl RuntimeThreadManager {
|
||||
tool_name,
|
||||
description,
|
||||
input,
|
||||
approval_key,
|
||||
approval_grouping_key,
|
||||
intent_summary,
|
||||
approval_force_prompt,
|
||||
@@ -17350,7 +17387,30 @@ impl RuntimeThreadManager {
|
||||
summary: Some(summary.clone()),
|
||||
};
|
||||
|
||||
if auto_approve {
|
||||
use crate::core::authority::{
|
||||
ApprovalRequestDisposition, TurnAuthority,
|
||||
resolve_approval_request_disposition,
|
||||
};
|
||||
let grant = self.session_grant_for(&thread_id, &approval_grouping_key);
|
||||
// This resolver reads the exact live approval posture only;
|
||||
// Core already owns mode and shell admission for this call.
|
||||
let approval_authority = TurnAuthority::from_effective_fields(
|
||||
codewhale_config::AppMode::Agent,
|
||||
true,
|
||||
trust_mode,
|
||||
auto_approve,
|
||||
approval_mode,
|
||||
);
|
||||
let disposition = resolve_approval_request_disposition(
|
||||
&approval_authority,
|
||||
grant.is_some(),
|
||||
false, // Runtime has grants, but no session-denial cache.
|
||||
approval_force_prompt,
|
||||
// Rust mints this namespace; tool text cannot claim origin.
|
||||
approval_key.starts_with("extcall:ext:"),
|
||||
);
|
||||
|
||||
if disposition == ApprovalRequestDisposition::AutoApprove && auto_approve {
|
||||
// No waiter is registered on this path, but the emitted
|
||||
// identity still has to obey the one contract clients
|
||||
// read: `approval_id` is ours, `tool_call_id` is the
|
||||
@@ -17373,13 +17433,6 @@ impl RuntimeThreadManager {
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
let auto_decision =
|
||||
Self::approval_decision(auto_approve, trust_mode, false);
|
||||
let (dec_str, approved) = match auto_decision {
|
||||
RuntimeApprovalDecision::ApproveTool => ("allow", true),
|
||||
RuntimeApprovalDecision::DenyTool
|
||||
| RuntimeApprovalDecision::RetryWithFullAccess => ("deny", false),
|
||||
};
|
||||
// Emit approval.decided so external clients (GUI)
|
||||
// know the approval was resolved automatically and
|
||||
// can clear any pending approval UI. Without this
|
||||
@@ -17393,37 +17446,33 @@ impl RuntimeThreadManager {
|
||||
json!({
|
||||
"approval_id": approval_id,
|
||||
"tool_call_id": id,
|
||||
"decision": dec_str,
|
||||
"decision": "allow",
|
||||
"remember": false,
|
||||
"auto": true,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
if approved {
|
||||
let _ = engine
|
||||
.approve_tool_call_by(
|
||||
id,
|
||||
crate::approval_log::ApprovalDecider::Posture,
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
let _ = engine
|
||||
.deny_tool_call_by(
|
||||
id,
|
||||
crate::approval_log::ApprovalDecider::Posture,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let _ = engine
|
||||
.approve_tool_call_by(id, crate::approval_log::ApprovalDecider::Posture)
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Auto-Review never opens an approval modal. The engine
|
||||
// resolves gated tools under Auto itself, so reaching
|
||||
// this branch means a host injected the event directly:
|
||||
// fail closed (the audit trail stays authoritative)
|
||||
// instead of pausing the turn.
|
||||
if approval_mode == ApprovalMode::Auto {
|
||||
// The same typed dispositions used by TUI fail closed:
|
||||
// a Full Access policy hold or Never posture must not be
|
||||
// answered by posture or a remembered conversation grant.
|
||||
let denied_posture = match disposition {
|
||||
ApprovalRequestDisposition::AutoDenyFullAccessPolicyHold => {
|
||||
Some("full_access_policy_hold")
|
||||
}
|
||||
ApprovalRequestDisposition::AutoDenyAutoReview => Some("auto_review"),
|
||||
ApprovalRequestDisposition::AutoDenyNeverPosture => Some("never"),
|
||||
ApprovalRequestDisposition::AutoDenySessionDenied => Some("session_denied"),
|
||||
ApprovalRequestDisposition::AutoApprove
|
||||
| ApprovalRequestDisposition::Prompt => None,
|
||||
};
|
||||
if let Some(posture) = denied_posture {
|
||||
self.emit_event(
|
||||
&thread_id,
|
||||
Some(&turn_id),
|
||||
@@ -17435,7 +17484,7 @@ impl RuntimeThreadManager {
|
||||
"decision": "deny",
|
||||
"remember": false,
|
||||
"auto": true,
|
||||
"posture": "auto_review",
|
||||
"posture": posture,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -17449,9 +17498,8 @@ impl RuntimeThreadManager {
|
||||
// A session grant for this tool and argument class
|
||||
// answers the prompt without a modal and without touching
|
||||
// posture (E1). A forced prompt is never pre-answered.
|
||||
if !approval_force_prompt
|
||||
&& let Some(grant) =
|
||||
self.session_grant_for(&thread_id, &approval_grouping_key)
|
||||
if disposition == ApprovalRequestDisposition::AutoApprove
|
||||
&& let Some(grant) = grant
|
||||
{
|
||||
let approval_id = Self::mint_approval_id();
|
||||
self.emit_event(
|
||||
|
||||
@@ -15903,6 +15903,302 @@ async fn identical_raw_tool_call_ids_on_two_threads_stay_independently_gated() -
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drive Runtime's actual event monitor and inspect the Engine inbox, rather
|
||||
/// than restating the pure resolver's truth table (#6471).
|
||||
#[tokio::test]
|
||||
async fn runtime_approval_disposition_auto_paths_obey_force_and_posture() -> Result<()> {
|
||||
use crate::approval_log::ApprovalDecider;
|
||||
|
||||
for (posture, forced, remembered, approved, denied_posture) in [
|
||||
("full-access", false, false, true, None),
|
||||
("full-access", false, true, true, None),
|
||||
(
|
||||
"full-access",
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
Some("full_access_policy_hold"),
|
||||
),
|
||||
(
|
||||
"full-access",
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
Some("full_access_policy_hold"),
|
||||
),
|
||||
("never", false, false, false, Some("never")),
|
||||
("never", false, true, false, Some("never")),
|
||||
("ask", false, true, true, None),
|
||||
] {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
permission_posture: Some(
|
||||
if posture == "never" { "ask" } else { posture }.to_string(),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let mut harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "honor the exact approval disposition".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(matches!(
|
||||
harness.rx_op.recv().await,
|
||||
Some(Op::SendMessage(TurnSpec { .. }))
|
||||
));
|
||||
// Never is an internal live authority, not a public Runtime posture.
|
||||
if posture == "never" {
|
||||
harness.handle.publish_turn_authority(
|
||||
AppMode::Agent,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
ApprovalMode::Never,
|
||||
None,
|
||||
);
|
||||
}
|
||||
let grant = if remembered {
|
||||
Some(
|
||||
manager
|
||||
.add_session_grant(&thread.id, &turn.id, "bash", "policy-group", "s")
|
||||
.await
|
||||
.context("fixture conversation grant")?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::ApprovalRequired {
|
||||
approval_key: "policy-key".to_string(),
|
||||
approval_grouping_key: "policy-group".to_string(),
|
||||
id: "policy-call".to_string(),
|
||||
tool_name: "bash".to_string(),
|
||||
// Text and input cannot impersonate a Rust-minted origin key.
|
||||
description: "Requested by extension:claimed".to_string(),
|
||||
input: json!({"approval_key": "extcall:ext:claimed", "command": "echo fixture"}),
|
||||
intent_summary: None,
|
||||
approval_force_prompt: forced,
|
||||
})
|
||||
.await?;
|
||||
let decision =
|
||||
tokio::time::timeout(APPROVAL_READINESS_TIMEOUT, harness.recv_approval_decision())
|
||||
.await
|
||||
.context("automatic disposition must not open a modal")?;
|
||||
let expected = if approved {
|
||||
MockApprovalEvent::Approved {
|
||||
id: "policy-call".to_string(),
|
||||
}
|
||||
} else {
|
||||
MockApprovalEvent::Denied {
|
||||
id: "policy-call".to_string(),
|
||||
}
|
||||
};
|
||||
let by = if approved && posture == "ask" {
|
||||
ApprovalDecider::SessionRule
|
||||
} else {
|
||||
ApprovalDecider::Posture
|
||||
};
|
||||
assert_eq!(
|
||||
decision,
|
||||
Some((expected, Some(by))),
|
||||
"{posture}/{forced}/{remembered}"
|
||||
);
|
||||
assert_eq!(manager.pending_approvals_count(), 0);
|
||||
let detail = manager.get_thread_detail(&thread.id).await?;
|
||||
assert!(detail.pending_approvals.is_empty());
|
||||
assert_eq!(detail.approval_grants.len(), usize::from(remembered));
|
||||
let decided = manager
|
||||
.events_since(&thread.id, None)?
|
||||
.into_iter()
|
||||
.find(|event| {
|
||||
event.event == "approval.decided" && event.payload["tool_call_id"] == "policy-call"
|
||||
})
|
||||
.context("automatic decision receipt")?;
|
||||
assert_eq!(
|
||||
decided.payload["decision"],
|
||||
if approved { "allow" } else { "deny" }
|
||||
);
|
||||
assert_eq!(decided.payload["auto"], true);
|
||||
assert_eq!(decided.payload["remember"], false);
|
||||
if let Some(posture) = denied_posture {
|
||||
assert_eq!(decided.payload["posture"], posture);
|
||||
}
|
||||
if approved && posture == "ask" {
|
||||
assert_eq!(decided.payload["grant_id"], grant.unwrap().grant_id);
|
||||
} else {
|
||||
assert!(decided.payload.get("grant_id").is_none());
|
||||
}
|
||||
let approval_id = decided.payload["approval_id"]
|
||||
.as_str()
|
||||
.context("opaque decision identity")?;
|
||||
assert!(approval_id.starts_with("approval_"));
|
||||
assert_ne!(approval_id, "policy-call");
|
||||
for id in ["policy-call", approval_id] {
|
||||
assert!(!manager.deliver_external_approval(
|
||||
id,
|
||||
ExternalApprovalDecision::Allow { remember: false },
|
||||
));
|
||||
}
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(
|
||||
wait_for_terminal_turn(&manager, &turn.id).await?.status,
|
||||
RuntimeTurnStatus::Completed,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_approval_disposition_forced_requests_require_human() -> Result<()> {
|
||||
use crate::approval_log::ApprovalDecider;
|
||||
|
||||
for (posture, tool_name, extension_origin) in [
|
||||
("full-access", "bash", true),
|
||||
("full-access", "web_search", true),
|
||||
("ask", "bash", false),
|
||||
] {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
let thread = manager
|
||||
.create_thread(CreateThreadRequest {
|
||||
permission_posture: Some(posture.to_string()),
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
let mut harness = install_mock_engine(&manager, &thread.id).await;
|
||||
let turn = manager
|
||||
.start_turn(
|
||||
&thread.id,
|
||||
StartTurnRequest {
|
||||
prompt: "wait for the exact human decision".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
assert!(matches!(
|
||||
harness.rx_op.recv().await,
|
||||
Some(Op::SendMessage(TurnSpec { .. }))
|
||||
));
|
||||
let input = json!({"command": "echo fixture", "query": "fixture"});
|
||||
let (key, group) = if extension_origin {
|
||||
crate::tools::approval_cache::extension_origin_approval_keys(
|
||||
"ext:fixture@reviewed-hash",
|
||||
None,
|
||||
tool_name,
|
||||
&input,
|
||||
)
|
||||
} else {
|
||||
crate::tools::approval_cache::approval_keys_for_call(None, tool_name, &input)
|
||||
};
|
||||
let grant = manager
|
||||
.add_session_grant(&thread.id, &turn.id, tool_name, &group.0, "s")
|
||||
.await
|
||||
.context("fixture conversation grant")?;
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::ApprovalRequired {
|
||||
approval_key: key.0,
|
||||
approval_grouping_key: group.0,
|
||||
id: "human-call".to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
description: "exact human approval required".to_string(),
|
||||
input,
|
||||
intent_summary: None,
|
||||
approval_force_prompt: true,
|
||||
})
|
||||
.await?;
|
||||
let approval_id = await_approval_identity(&manager, &thread.id, "human-call").await?;
|
||||
let detail = manager.get_thread_detail(&thread.id).await?;
|
||||
assert_eq!(detail.pending_approvals.len(), 1, "{posture}/{tool_name}");
|
||||
assert_eq!(detail.pending_approvals[0].id, approval_id);
|
||||
assert_eq!(detail.pending_approvals[0].turn_id, turn.id);
|
||||
assert_eq!(
|
||||
detail.pending_approvals[0].tool_call_id.as_deref(),
|
||||
Some("human-call")
|
||||
);
|
||||
assert!(
|
||||
tokio::time::timeout(Duration::from_millis(100), harness.recv_approval_decision())
|
||||
.await
|
||||
.is_err(),
|
||||
"neither Full Access nor a remembered grant may answer: {posture}/{tool_name}"
|
||||
);
|
||||
assert!(!manager.deliver_external_approval(
|
||||
"human-call",
|
||||
ExternalApprovalDecision::Allow { remember: false },
|
||||
));
|
||||
assert!(manager.deliver_external_approval(
|
||||
&approval_id,
|
||||
ExternalApprovalDecision::Allow { remember: false },
|
||||
));
|
||||
assert_eq!(
|
||||
tokio::time::timeout(APPROVAL_READINESS_TIMEOUT, harness.recv_approval_decision())
|
||||
.await?,
|
||||
Some((
|
||||
MockApprovalEvent::Approved {
|
||||
id: "human-call".to_string()
|
||||
},
|
||||
Some(ApprovalDecider::User),
|
||||
)),
|
||||
);
|
||||
assert_eq!(manager.pending_approvals_count(), 0);
|
||||
assert!(!manager.deliver_external_approval(
|
||||
&approval_id,
|
||||
ExternalApprovalDecision::Allow { remember: false },
|
||||
));
|
||||
assert_eq!(
|
||||
manager.approval_grants_for_thread(&thread.id)[0].grant_id,
|
||||
grant.grant_id
|
||||
);
|
||||
let decided = manager
|
||||
.events_since(&thread.id, None)?
|
||||
.into_iter()
|
||||
.find(|event| {
|
||||
event.event == "approval.decided" && event.payload["approval_id"] == approval_id
|
||||
})
|
||||
.context("human decision receipt")?;
|
||||
assert_eq!(decided.payload["decision"], "allow");
|
||||
assert_ne!(
|
||||
decided.payload.get("auto").and_then(Value::as_bool),
|
||||
Some(true)
|
||||
);
|
||||
harness
|
||||
.tx_event
|
||||
.send(EngineEvent::TurnComplete {
|
||||
usage: Usage::default(),
|
||||
parent_route_usage: Usage::default(),
|
||||
routed_usage_dropped_records: 0,
|
||||
status: TurnOutcomeStatus::Completed,
|
||||
error: None,
|
||||
tool_catalog: None,
|
||||
base_url: None,
|
||||
})
|
||||
.await?;
|
||||
assert_eq!(
|
||||
wait_for_terminal_turn(&manager, &turn.id).await?.status,
|
||||
RuntimeTurnStatus::Completed,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_review_force_prompt_is_denied_without_opening_a_modal() -> Result<()> {
|
||||
let manager = test_manager(test_runtime_dir())?;
|
||||
|
||||
Reference in new issue
Block a user