fix: adapt T-769 recovery onto current restore lane checkpoint
UNCOMPILED TRANSPORT / PRESERVATION CHECKPOINT, NOT A VALIDATED CANDIDATE. One-time recovery authorization permits publication before validation so the parent can validate this exact source in a separate Workdir. Prior compilation attempts were interrupted by Workdir-session unavailability; cause and test outcome remain unknown. No Cargo build/check/test was started in this bounded checkpoint turn. Merge current lane4ef7b03651into existing sourceb7a22146a0. Selectively adapt typed factory failure and same-operation cleanup/replay while preserving current saved Feature restore and operation-time authorization, logical/deferred Workdirs, T765 Host/schema and T768 replay-safe Subject grant. No T770/T771 implementation. All 19 target-relative paths and added test bodies were source-audited. Workspace fmt check, scoped rustfmt, whitespace/diff checks passed; conflict markers zero. Excluded unrelated formatter changes and untracked compiler artifacts. Local audit: target/t769-checkpoint/AUDIT.md. Handoff to parent: compile and run focused and package/API regressions on this exact source, fix as necessary, then obtain fresh independent review. Existing MR 01a15606-a6ba-7d23-8d60-0874607399e7 only; no new review requested here. No acceptance, approval, adoption, target/develop push, integration, Ticket closure or service update is asserted.
This commit is contained in:
@@ -91,11 +91,38 @@ pub fn parse_bearer(headers: &HeaderMap) -> Option<String> {
|
||||
}
|
||||
|
||||
pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
|
||||
let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?;
|
||||
cookie_header.split(';').find_map(|pair| {
|
||||
let (name, value) = pair.trim().split_once('=')?;
|
||||
(name == cookie_name && !value.is_empty()).then(|| value.to_string())
|
||||
})
|
||||
unique_cookie(headers, cookie_name)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
// Cookie has no path/domain metadata on requests. Duplicates (including identical values
|
||||
// and multiple Cookie headers) cannot be disambiguated safely after cookie tossing.
|
||||
fn unique_cookie(
|
||||
headers: &HeaderMap,
|
||||
cookie_name: &str,
|
||||
) -> std::result::Result<Option<String>, ()> {
|
||||
let mut found = None;
|
||||
for header in headers.get_all(header::COOKIE) {
|
||||
let header = header.to_str().map_err(|_| ())?;
|
||||
for pair in header.split(';') {
|
||||
let pair = pair.trim();
|
||||
let Some((name, value)) = pair.split_once('=') else {
|
||||
if pair == cookie_name {
|
||||
return Err(());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if name == cookie_name {
|
||||
if found.is_some() {
|
||||
return Err(());
|
||||
}
|
||||
found = Some(value.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub async fn resolve_request_actor(
|
||||
@@ -121,6 +148,14 @@ pub(crate) fn resolve_request_actor_sync(
|
||||
bearer: Option<String>,
|
||||
session: Option<String>,
|
||||
) -> Result<Option<RequestActor>> {
|
||||
Ok(resolve_credentials_sync(store, bearer, session)?.map(|(actor, _)| actor))
|
||||
}
|
||||
|
||||
fn resolve_credentials_sync(
|
||||
store: &dyn ControlPlaneStore,
|
||||
bearer: Option<String>,
|
||||
session: Option<String>,
|
||||
) -> Result<Option<(RequestActor, Option<String>)>> {
|
||||
if let Some(token) = bearer {
|
||||
let token_hash = token_hash(&token);
|
||||
if let Some(api_token) = store.resolve_api_token(&token_hash)? {
|
||||
@@ -132,7 +167,10 @@ pub(crate) fn resolve_request_actor_sync(
|
||||
return Ok(None);
|
||||
}
|
||||
store.mark_api_token_used(&token_hash, &now_rfc3339())?;
|
||||
return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken);
|
||||
return Ok(
|
||||
actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken)?
|
||||
.map(|actor| (actor, None)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,13 +179,95 @@ pub(crate) fn resolve_request_actor_sync(
|
||||
if is_expired(&session.expires_at) {
|
||||
return Ok(None);
|
||||
}
|
||||
return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession);
|
||||
return Ok(
|
||||
actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession)?
|
||||
.map(|actor| (actor, Some(session.expires_at))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// This marker is a non-authenticating reference to the existing non-revoked session row.
|
||||
// It outlives the auth cookie so Max-Age expiry remains distinguishable from a new browser.
|
||||
// No second session store is introduced; clearing all cookies necessarily loses this evidence.
|
||||
// Keep the established 14-day authentication lifetime. The additional week is only a bounded
|
||||
// return-navigation window (including a week away); it never extends authentication. The store
|
||||
// reclaims expired rows past this same boundary on subsequent session issuance. Neither whoami
|
||||
// nor other reads refresh the marker or the row's absolute expiry.
|
||||
pub const BROWSER_SESSION_SECONDS: i64 = 14 * 24 * 60 * 60;
|
||||
pub const SESSION_EXPIRY_GRACE_SECONDS: i64 = 7 * 24 * 60 * 60;
|
||||
|
||||
pub fn session_marker_cookie_name(cookie_name: &str) -> String {
|
||||
format!("{cookie_name}_expiry")
|
||||
}
|
||||
|
||||
pub async fn resolve_whoami(
|
||||
store: std::sync::Arc<dyn ControlPlaneStore>,
|
||||
headers: &HeaderMap,
|
||||
cookie_name: &str,
|
||||
) -> Result<server_api::WhoamiResponse> {
|
||||
let bearer = parse_bearer(headers);
|
||||
// Even an empty/invalid auth cookie takes precedence over an older marker.
|
||||
// An ambiguous primary is invalid, not absent; never fall back to an older marker.
|
||||
let session = unique_cookie(headers, cookie_name).unwrap_or_else(|()| Some(String::new()));
|
||||
let marker = parse_cookie(headers, &session_marker_cookie_name(cookie_name));
|
||||
let executor = store.db_executor();
|
||||
executor
|
||||
.run(move || resolve_whoami_sync(store.as_ref(), bearer, session, marker))
|
||||
.await
|
||||
}
|
||||
|
||||
fn resolve_whoami_sync(
|
||||
store: &dyn ControlPlaneStore,
|
||||
bearer: Option<String>,
|
||||
session_token: Option<String>,
|
||||
marker: Option<String>,
|
||||
) -> Result<server_api::WhoamiResponse> {
|
||||
use server_api::{BrowserSessionState, WhoamiResponse};
|
||||
let anonymous = || WhoamiResponse {
|
||||
actor: None,
|
||||
session: BrowserSessionState::Anonymous {},
|
||||
};
|
||||
// Preserve the existing API-token precedence and fallback rules, including expired tokens.
|
||||
if let Some((actor, expires_at)) =
|
||||
resolve_credentials_sync(store, bearer, session_token.clone())?
|
||||
{
|
||||
return Ok(WhoamiResponse {
|
||||
actor: Some(actor),
|
||||
session: BrowserSessionState::Authenticated { expires_at },
|
||||
});
|
||||
}
|
||||
let hash = match session_token {
|
||||
Some(token) => token_hash(&token),
|
||||
None => match marker {
|
||||
Some(hash) => hash,
|
||||
None => return Ok(anonymous()),
|
||||
},
|
||||
};
|
||||
let Some(session) = store.resolve_browser_session(&hash)? else {
|
||||
return Ok(anonymous());
|
||||
};
|
||||
if store.get_user(&session.user_id)?.is_none() {
|
||||
return Ok(anonymous());
|
||||
}
|
||||
if within_session_expiry_grace(&session.expires_at, Utc::now()) {
|
||||
return Ok(WhoamiResponse {
|
||||
actor: None,
|
||||
session: BrowserSessionState::Expired {},
|
||||
});
|
||||
}
|
||||
// A marker for a still-valid session cannot restore authentication after partial deletion.
|
||||
Ok(anonymous())
|
||||
}
|
||||
|
||||
fn within_session_expiry_grace(expires_at: &str, now: DateTime<Utc>) -> bool {
|
||||
DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| {
|
||||
expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS)
|
||||
})
|
||||
}
|
||||
|
||||
fn actor_for_user<S: ControlPlaneStore + ?Sized>(
|
||||
store: &S,
|
||||
user_id: &str,
|
||||
@@ -274,6 +394,13 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(actor.user_id, "user-auth");
|
||||
assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken);
|
||||
let whoami = resolve_whoami(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
whoami.session,
|
||||
server_api::BrowserSessionState::Authenticated { expires_at: None }
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.resolve_api_token(&hash)
|
||||
@@ -363,20 +490,119 @@ mod tests {
|
||||
assert!(futures::poll!(queued.as_mut()).is_pending());
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::COOKIE, "session=session-secret".parse().unwrap());
|
||||
let actor_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
resolve_request_actor(store.clone(), &headers, "session"),
|
||||
)
|
||||
.await;
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(1),
|
||||
resolve_request_actor(store, &headers, "session"),
|
||||
resolve_whoami(store, &headers, "session"),
|
||||
)
|
||||
.await;
|
||||
release.send(()).unwrap();
|
||||
blocker.await.unwrap().unwrap();
|
||||
queued.await.unwrap();
|
||||
assert!(matches!(
|
||||
actor_result.expect("actor lookup rejects capacity"),
|
||||
Err(Error::DbCapacityExceeded)
|
||||
));
|
||||
assert!(matches!(
|
||||
result.expect("capacity rejection must not wait for queued SQL"),
|
||||
Err(Error::DbCapacityExceeded)
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn whoami_classifies_only_live_store_backed_session_expiry() {
|
||||
use server_api::BrowserSessionState;
|
||||
let store = seeded_store();
|
||||
for (secret, offset, revoked) in [
|
||||
("recent", -60, false),
|
||||
("old", -SESSION_EXPIRY_GRACE_SECONDS - 60, false),
|
||||
("revoked", -60, true),
|
||||
] {
|
||||
store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
session_id: format!("session-{secret}"),
|
||||
token_hash: token_hash(secret),
|
||||
user_id: "user-auth".into(),
|
||||
created_at: "1".into(),
|
||||
expires_at: rfc3339_after(Duration::seconds(offset)),
|
||||
revoked_at: revoked.then(|| now_rfc3339()),
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let cases = [
|
||||
(String::new(), BrowserSessionState::Anonymous {}),
|
||||
("session=unknown".into(), BrowserSessionState::Anonymous {}),
|
||||
("session=recent".into(), BrowserSessionState::Expired {}),
|
||||
(
|
||||
format!("session_expiry={}", token_hash("recent")),
|
||||
BrowserSessionState::Expired {},
|
||||
),
|
||||
(
|
||||
format!("session_expiry={}", token_hash("session-secret")),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
(
|
||||
"session_expiry=unknown".into(),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
(
|
||||
"session_expiry=recent".into(),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
(
|
||||
format!("session=unknown; session_expiry={}", token_hash("recent")),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
(
|
||||
format!("session=; session_expiry={}", token_hash("recent")),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
("session=revoked".into(), BrowserSessionState::Anonymous {}),
|
||||
(
|
||||
format!("session_expiry={}", token_hash("revoked")),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
("session=old".into(), BrowserSessionState::Anonymous {}),
|
||||
(
|
||||
format!("session_expiry={}", token_hash("old")),
|
||||
BrowserSessionState::Anonymous {},
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"session=session-secret; session_expiry={}",
|
||||
token_hash("recent")
|
||||
),
|
||||
BrowserSessionState::Authenticated {
|
||||
expires_at: Some("9999-01-01T00:00:00Z".into()),
|
||||
},
|
||||
),
|
||||
];
|
||||
for (cookie, expected) in cases {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(header::COOKIE, cookie.parse().unwrap());
|
||||
let response = resolve_whoami(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.session, expected, "cookie: {cookie}");
|
||||
assert_eq!(
|
||||
response.actor.is_some(),
|
||||
matches!(expected, BrowserSessionState::Authenticated { .. })
|
||||
);
|
||||
if !matches!(expected, BrowserSessionState::Authenticated { .. }) {
|
||||
assert!(
|
||||
resolve_request_actor(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_code_uses_the_uuid_random_tail() {
|
||||
let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID");
|
||||
@@ -395,4 +621,136 @@ mod tests {
|
||||
);
|
||||
assert_eq!(code, code.to_ascii_uppercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_grace_includes_expiry_but_excludes_its_absolute_end() {
|
||||
let expiry = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z")
|
||||
.unwrap()
|
||||
.with_timezone(&Utc);
|
||||
for (offset, expected) in [
|
||||
(-1, false),
|
||||
(0, true),
|
||||
(SESSION_EXPIRY_GRACE_SECONDS - 1, true),
|
||||
(SESSION_EXPIRY_GRACE_SECONDS, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
within_session_expiry_grace(
|
||||
&expiry.to_rfc3339(),
|
||||
expiry + Duration::seconds(offset)
|
||||
),
|
||||
expected
|
||||
);
|
||||
}
|
||||
assert!(!within_session_expiry_grace("invalid", expiry));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn duplicate_cookies_never_select_an_actor_or_promote_an_ambiguous_marker() {
|
||||
use server_api::BrowserSessionState;
|
||||
let store = seeded_store();
|
||||
let expired_hash = token_hash("expired-duplicate");
|
||||
store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
session_id: "expired-duplicate".into(),
|
||||
token_hash: expired_hash.clone(),
|
||||
user_id: "user-auth".into(),
|
||||
created_at: now_rfc3339(),
|
||||
expires_at: rfc3339_after(Duration::seconds(-60)),
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
for cookies in [
|
||||
vec!["session=session-secret; session=unknown".to_owned()],
|
||||
vec!["session=session-secret; session=session-secret".to_owned()],
|
||||
vec![
|
||||
"session=session-secret".to_owned(),
|
||||
"session=unknown".to_owned(),
|
||||
],
|
||||
vec![format!(
|
||||
"session=; session=session-secret; session_expiry={expired_hash}"
|
||||
)],
|
||||
vec![format!(
|
||||
"session_expiry={expired_hash}; session_expiry={expired_hash}"
|
||||
)],
|
||||
vec![format!("session; session_expiry={expired_hash}")],
|
||||
] {
|
||||
let mut headers = HeaderMap::new();
|
||||
for cookie in cookies {
|
||||
headers.append(header::COOKIE, cookie.parse().unwrap());
|
||||
}
|
||||
assert!(
|
||||
resolve_request_actor(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_whoami(store.clone(), &headers, "session")
|
||||
.await
|
||||
.unwrap()
|
||||
.session,
|
||||
BrowserSessionState::Anonymous {}
|
||||
);
|
||||
}
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
header::COOKIE,
|
||||
format!("session=session-secret; session_expiry={expired_hash}")
|
||||
.parse()
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(matches!(
|
||||
resolve_whoami(store, &headers, "session")
|
||||
.await
|
||||
.unwrap()
|
||||
.session,
|
||||
BrowserSessionState::Authenticated { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_issuance_reclaims_only_rows_beyond_recovery_grace() {
|
||||
let store = seeded_store();
|
||||
let record = |secret: &str, offset| BrowserSessionRecord {
|
||||
session_id: secret.into(),
|
||||
token_hash: token_hash(secret),
|
||||
user_id: "user-auth".into(),
|
||||
created_at: now_rfc3339(),
|
||||
expires_at: rfc3339_after(Duration::seconds(offset)),
|
||||
revoked_at: None,
|
||||
};
|
||||
store
|
||||
.create_browser_session(&record("past-grace", -SESSION_EXPIRY_GRACE_SECONDS - 60))
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.resolve_browser_session(&token_hash("past-grace"))
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
store
|
||||
.create_browser_session(&record("within-grace", -SESSION_EXPIRY_GRACE_SECONDS + 60))
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.resolve_browser_session(&token_hash("past-grace"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
store
|
||||
.create_browser_session(&record("new-session", BROWSER_SESSION_SECONDS))
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.resolve_browser_session(&token_hash("within-grace"))
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.resolve_browser_session(&token_hash("new-session"))
|
||||
.unwrap()
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -567,6 +567,10 @@ pub struct WorkerSpawnRequest {
|
||||
/// Trusted Backend operation identity used to make Worker-owned spawns replay-safe.
|
||||
#[serde(skip, default)]
|
||||
pub resolved_control_operation: Option<WorkerControlOperation>,
|
||||
/// Host-derived child execution ceiling, persisted against the ordinary
|
||||
/// Worker reservation before Runtime activation. Not accepted from JSON.
|
||||
#[serde(skip, default)]
|
||||
pub resolved_execution_grant: Option<server_api::authorization::WorkerExecutionGrant>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -7892,6 +7896,7 @@ mod tests {
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
resolved_subjektiv_attached: false,
|
||||
@@ -8182,6 +8187,7 @@ mod tests {
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
resolved_subjektiv_attached: false,
|
||||
@@ -8294,6 +8300,7 @@ mod tests {
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
resolved_subjektiv_attached: false,
|
||||
@@ -8335,6 +8342,7 @@ mod tests {
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
resolved_workspace_api: Some(test_workspace_api()),
|
||||
resolved_memory_settings: Some(test_memory_settings()),
|
||||
resolved_subjektiv_attached: false,
|
||||
|
||||
+1121
-424
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
||||
//! Host binding for cooperative Feature authorization. Profile configuration requests
|
||||
//! capabilities; it never issues them. Non-Subject Workers retain their existing
|
||||
//! Backend/provider contracts. Subject bodies with no assignment deny by default.
|
||||
//!
|
||||
//! Bindings here are execution grants, not another Worker registry. Current body
|
||||
//! ownership remains the singleton store. Replacing a permission assignment fences
|
||||
//! all children issued from it; restoring a Worker cannot refresh that assignment.
|
||||
//! Checks refuse new effects; they do not undo effects already committed. Trusted
|
||||
//! Features must call the operation boundary themselves, including alternate surfaces.
|
||||
//!
|
||||
//! Subject delegation uses the normal Worker control lifecycle, which binds stable
|
||||
//! operation identity to the authenticated origin and recovers control-grant writes.
|
||||
//! Generic Worker creation rejects Subject delegation before reservation, even if
|
||||
//! the payload supplies an operation ID; its manual allocations are not replay-safe.
|
||||
|
||||
use super::*;
|
||||
use server_api::authorization::{
|
||||
OperationAuthorizationRequest, PermissionResource, ScopedPermission, SubjectPermissions,
|
||||
WorkerDelegationRequest, WorkerExecutionGrant,
|
||||
};
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
fn denied(message: impl Into<String>) -> ApiError {
|
||||
Error::WorkspacePermissionDenied(message.into()).into()
|
||||
}
|
||||
|
||||
fn policy(api: &WorkspaceApi, subject_id: &str) -> ApiResult<Option<SubjectPermissions>> {
|
||||
let store = open_subjektiv_store(api)?;
|
||||
let subject = store
|
||||
.subject(subject_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
.ok_or_else(|| denied("Subject permission source no longer exists"))?;
|
||||
if subject.state != crate::subjektiv::SubjectState::Active {
|
||||
return Err(denied("Subject permission source is retired"));
|
||||
}
|
||||
Ok(store
|
||||
.subject_permissions(subject_id)
|
||||
.map_err(subjektiv_store_error)?)
|
||||
}
|
||||
|
||||
fn current_body_subject(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> ApiResult<Option<String>> {
|
||||
let retained = api
|
||||
.store
|
||||
.retained_worker_singleton_lease(&api.config.workspace_id, worker)?;
|
||||
let Some(retained) = retained else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(subject) = retained.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) else {
|
||||
return Ok(None);
|
||||
};
|
||||
// This also rejects a retained lease from a released/replaced body. Never
|
||||
// fall through to non-Subject compatibility after ownership has ended.
|
||||
let current = api
|
||||
.store
|
||||
.require_current_worker_singleton_owner(&api.config.workspace_id, worker)?
|
||||
.ok_or_else(|| denied("Subject body no longer owns its singleton"))?;
|
||||
if current.key != retained.key {
|
||||
return Err(denied("Subject body ownership changed"));
|
||||
}
|
||||
Ok(Some(subject.to_owned()))
|
||||
}
|
||||
|
||||
fn live_child_grant(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> ApiResult<Option<WorkerExecutionGrant>> {
|
||||
let store = open_subjektiv_store(api)?;
|
||||
let Some(grant) = store
|
||||
.worker_execution_grant(&worker.runtime_id, &worker.worker_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let source =
|
||||
policy(api, &grant.subject_id)?.ok_or_else(|| denied("Subject permissions are unset"))?;
|
||||
if source.change_id != grant.source_change_id {
|
||||
return Err(denied(
|
||||
"delegated execution grant was revoked by permission replacement",
|
||||
));
|
||||
}
|
||||
let mut current = grant.clone();
|
||||
let mut visited = HashSet::new();
|
||||
for _ in 0..64 {
|
||||
if !visited.insert((current.runtime_id.clone(), current.worker_id.clone())) {
|
||||
return Err(denied("cyclic execution grant lineage"));
|
||||
}
|
||||
if current.subject_id != grant.subject_id
|
||||
|| current.source_change_id != source.change_id
|
||||
|| current
|
||||
.permissions
|
||||
.iter()
|
||||
.chain(current.redelegation.iter())
|
||||
.any(|permission| {
|
||||
!source
|
||||
.delegation
|
||||
.iter()
|
||||
.any(|ceiling| ceiling.covers(permission))
|
||||
})
|
||||
{
|
||||
return Err(denied(
|
||||
"delegated execution exceeds the current Subject ceiling",
|
||||
));
|
||||
}
|
||||
let origin = RuntimeWorkerRef::new(¤t.origin_runtime_id, ¤t.origin_worker_id);
|
||||
match store
|
||||
.worker_execution_grant(&origin.runtime_id, &origin.worker_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
{
|
||||
Some(parent) => {
|
||||
if current
|
||||
.permissions
|
||||
.iter()
|
||||
.chain(current.redelegation.iter())
|
||||
.any(|permission| {
|
||||
!parent
|
||||
.redelegation
|
||||
.iter()
|
||||
.any(|ceiling| ceiling.covers(permission))
|
||||
})
|
||||
{
|
||||
return Err(denied(
|
||||
"execution grant exceeds its immediate parent's redelegation ceiling",
|
||||
));
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
None => {
|
||||
if current_body_subject(api, &origin)?.as_deref() != Some(grant.subject_id.as_str())
|
||||
{
|
||||
return Err(denied(
|
||||
"delegation origin is no longer the current Subject body",
|
||||
));
|
||||
}
|
||||
return Ok(Some(grant));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(denied(
|
||||
"execution grant lineage exceeds the bounded delegation depth",
|
||||
))
|
||||
}
|
||||
|
||||
/// None is explicitly the preexisting non-Subject contract, not an all-powerful
|
||||
/// grant. Existing Workdir/provider/Ticket checks still apply to those Workers.
|
||||
pub(super) fn worker_permissions(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> ApiResult<Option<Vec<ScopedPermission>>> {
|
||||
if let Some(grant) = live_child_grant(api, worker)? {
|
||||
return Ok(Some(grant.permissions));
|
||||
}
|
||||
let Some(subject) = current_body_subject(api, worker)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some(
|
||||
policy(api, &subject)?
|
||||
.map(|policy| policy.execution)
|
||||
.unwrap_or_default(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn authorize(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
request: &OperationAuthorizationRequest,
|
||||
) -> ApiResult<()> {
|
||||
authorize_scope(api, worker, request).map(|_| ())
|
||||
}
|
||||
|
||||
pub(super) fn authorize_scope(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
request: &OperationAuthorizationRequest,
|
||||
) -> ApiResult<Option<PermissionResource>> {
|
||||
request.validate().map_err(denied)?;
|
||||
if let PermissionResource::Workspace { workspace_id } = &request.resource {
|
||||
if workspace_id != &api.config.workspace_id {
|
||||
return Err(denied("resource belongs to another Workspace"));
|
||||
}
|
||||
}
|
||||
let needed = ScopedPermission {
|
||||
operation: request.operation.clone(),
|
||||
resource: request.resource.clone(),
|
||||
};
|
||||
let Some(permissions) = worker_permissions(api, worker)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(permission) = permissions
|
||||
.iter()
|
||||
.find(|permission| permission.covers(&needed))
|
||||
{
|
||||
return Ok(Some(permission.resource.clone()));
|
||||
}
|
||||
Err(denied(format!(
|
||||
"operation `{}` is not granted for the requested resource",
|
||||
request.operation
|
||||
)))
|
||||
}
|
||||
|
||||
pub(super) fn source_from_context(
|
||||
context: &server_api::ServerRequestContext,
|
||||
) -> ApiResult<RuntimeWorkerRef> {
|
||||
let source = context
|
||||
.runtime_source
|
||||
.as_ref()
|
||||
.ok_or_else(|| denied("authorization requires a verified Runtime source"))?;
|
||||
let worker_id = source
|
||||
.worker_id
|
||||
.as_deref()
|
||||
.ok_or_else(|| denied("authorization requires Runtime-bound Worker identity"))?;
|
||||
Ok(RuntimeWorkerRef::new(&source.runtime_id, worker_id))
|
||||
}
|
||||
|
||||
/// Resolves authority from the authenticated origin, independently of its enabled
|
||||
/// Feature/tool set. A model cannot nominate a Subject or inherit body identity.
|
||||
pub(super) fn prepare_delegation(
|
||||
api: &WorkspaceApi,
|
||||
source: &RuntimeWorkerRef,
|
||||
requested: Option<&WorkerDelegationRequest>,
|
||||
runtime_id: &str,
|
||||
profile: &str,
|
||||
profile_digest: &str,
|
||||
singleton_key: Option<&str>,
|
||||
subject_connection: bool,
|
||||
) -> ApiResult<Option<WorkerExecutionGrant>> {
|
||||
let child = live_child_grant(api, source)?;
|
||||
let body = if child.is_none() {
|
||||
current_body_subject(api, source)?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if child.is_none() && body.is_none() {
|
||||
if requested.is_some() {
|
||||
return Err(denied(
|
||||
"only an authenticated Subject delegation source can issue an execution grant",
|
||||
));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
if singleton_key.is_some() || subject_connection {
|
||||
return Err(denied(
|
||||
"delegated Workers cannot acquire a Subject body or singleton identity",
|
||||
));
|
||||
}
|
||||
let requested = requested.ok_or_else(|| {
|
||||
denied("Subject delegation requires explicit least-authority permissions")
|
||||
})?;
|
||||
requested.validate().map_err(denied)?;
|
||||
let (subject_id, origin_runtime_id, origin_worker_id, source_change_id, ceiling) = match child {
|
||||
Some(grant) => (
|
||||
grant.subject_id,
|
||||
source.runtime_id.clone(),
|
||||
source.worker_id.clone(),
|
||||
grant.source_change_id,
|
||||
grant.redelegation,
|
||||
),
|
||||
None => {
|
||||
let subject = body.expect("checked Subject body");
|
||||
let source_policy = policy(api, &subject)?.ok_or_else(|| {
|
||||
denied(
|
||||
"Subject permissions are unset; an authorized administrator must assign them",
|
||||
)
|
||||
})?;
|
||||
(
|
||||
subject,
|
||||
source.runtime_id.clone(),
|
||||
source.worker_id.clone(),
|
||||
source_policy.change_id,
|
||||
source_policy.delegation,
|
||||
)
|
||||
}
|
||||
};
|
||||
if requested
|
||||
.permissions
|
||||
.iter()
|
||||
.chain(requested.redelegation.iter())
|
||||
.any(|permission| !ceiling.iter().any(|allowed| allowed.covers(permission)))
|
||||
{
|
||||
return Err(denied(
|
||||
"requested delegation exceeds explicit operations, resources or redelegation ceiling",
|
||||
));
|
||||
}
|
||||
if requested.redelegation.iter().any(|permission| {
|
||||
!requested
|
||||
.permissions
|
||||
.iter()
|
||||
.any(|allowed| allowed.covers(permission))
|
||||
}) {
|
||||
return Err(denied(
|
||||
"redelegation must be within this Worker's effective grant",
|
||||
));
|
||||
}
|
||||
Ok(Some(WorkerExecutionGrant {
|
||||
subject_id,
|
||||
origin_runtime_id,
|
||||
origin_worker_id,
|
||||
source_change_id,
|
||||
runtime_id: runtime_id.to_owned(),
|
||||
worker_id: String::new(),
|
||||
profile: profile.to_owned(),
|
||||
profile_digest: profile_digest.to_owned(),
|
||||
permissions: requested.permissions.clone(),
|
||||
redelegation: requested.redelegation.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn bind_reserved_grant(
|
||||
api: &WorkspaceApi,
|
||||
grant: &WorkerExecutionGrant,
|
||||
worker_id: &str,
|
||||
) -> ApiResult<()> {
|
||||
let mut bound = grant.clone();
|
||||
bound.worker_id = worker_id.to_owned();
|
||||
Ok(open_subjektiv_store(api)?
|
||||
.bind_worker_execution_grant(&bound)
|
||||
.map_err(subjektiv_store_error)?)
|
||||
}
|
||||
|
||||
/// Host-owned binding for an already authenticated Backend operation. No model
|
||||
/// input can choose the captured Worker or turn a shared client into a grant.
|
||||
#[derive(Clone)]
|
||||
struct BoundOperationClient {
|
||||
api: WorkspaceApi,
|
||||
worker: RuntimeWorkerRef,
|
||||
}
|
||||
impl std::fmt::Debug for BoundOperationClient {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("BoundOperationClient")
|
||||
.field("worker", &self.worker)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
impl worker::WorkspaceClient for BoundOperationClient {
|
||||
fn workspace_id(&self) -> Option<&str> {
|
||||
Some(&self.api.config.workspace_id)
|
||||
}
|
||||
fn kind(&self) -> &str {
|
||||
"backend-bound-operation"
|
||||
}
|
||||
fn is_available(&self) -> bool {
|
||||
true
|
||||
}
|
||||
fn execute(
|
||||
&self,
|
||||
_: worker::WorkspaceRequest,
|
||||
) -> std::result::Result<worker::WorkspaceResponse, worker::WorkspaceClientError> {
|
||||
Err(worker::WorkspaceClientError::Unavailable(
|
||||
"this binding provides authorization only".into(),
|
||||
))
|
||||
}
|
||||
fn authorize_operation(
|
||||
&self,
|
||||
operation: &str,
|
||||
resource: &PermissionResource,
|
||||
) -> std::result::Result<(), worker::WorkspaceClientError> {
|
||||
authorize(
|
||||
&self.api,
|
||||
&self.worker,
|
||||
&OperationAuthorizationRequest {
|
||||
operation: operation.into(),
|
||||
resource: resource.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|error| worker::WorkspaceClientError::Request(error.error.to_string()))
|
||||
}
|
||||
fn authorize_operation_scope(
|
||||
&self,
|
||||
operation: &str,
|
||||
resource: &PermissionResource,
|
||||
) -> std::result::Result<Option<PermissionResource>, worker::WorkspaceClientError> {
|
||||
authorize_scope(
|
||||
&self.api,
|
||||
&self.worker,
|
||||
&OperationAuthorizationRequest {
|
||||
operation: operation.into(),
|
||||
resource: resource.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|error| worker::WorkspaceClientError::Request(error.error.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn bind_operation_client(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
) -> Arc<dyn worker::WorkspaceClient> {
|
||||
Arc::new(BoundOperationClient {
|
||||
api: api.clone(),
|
||||
worker: worker.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn interrupt_subject_workers(
|
||||
api: &WorkspaceApi,
|
||||
subject_id: &str,
|
||||
) -> Vec<server_api::SubjectPermissionInterruption> {
|
||||
let mut workers = BTreeSet::new();
|
||||
let collected = (|| -> ApiResult<()> {
|
||||
for grant in open_subjektiv_store(api)?
|
||||
.list_subject_worker_execution_grants(subject_id)
|
||||
.map_err(subjektiv_store_error)?
|
||||
{
|
||||
workers.insert((grant.runtime_id, grant.worker_id));
|
||||
}
|
||||
let key = crate::subjektiv::subject_worker_singleton_key(subject_id)
|
||||
.map_err(subjektiv_store_error)?;
|
||||
if let Some(body) = api
|
||||
.store
|
||||
.current_worker_singleton_owner(&api.config.workspace_id, &key)?
|
||||
{
|
||||
workers.insert((body.worker.runtime_id, body.worker.worker_id));
|
||||
}
|
||||
Ok(())
|
||||
})();
|
||||
if let Err(error) = collected {
|
||||
return vec![server_api::SubjectPermissionInterruption {
|
||||
runtime_id: String::new(),
|
||||
worker_id: String::new(),
|
||||
cancellation_accepted: false,
|
||||
backend_command_sessions_closed: false,
|
||||
diagnostics: vec![format!(
|
||||
"permissions committed; interruption targets could not be fully collected: {}",
|
||||
error.error
|
||||
)],
|
||||
}];
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
for (runtime_id, worker_id) in workers {
|
||||
let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id);
|
||||
let mut evidence = server_api::SubjectPermissionInterruption {
|
||||
runtime_id,
|
||||
worker_id,
|
||||
cancellation_accepted: false,
|
||||
backend_command_sessions_closed: false,
|
||||
diagnostics: Vec::new(),
|
||||
};
|
||||
let cancellation_api = api.clone();
|
||||
let cancellation_worker = worker.clone();
|
||||
let cancellation = match api.host_executors.host() {
|
||||
Ok(host) => host.run(move || -> ApiResult<_> {
|
||||
Ok(cancellation_api.runtime.cancel_worker(&cancellation_worker, WorkerLifecycleRequest {
|
||||
reason: Some("Subject permission assignment changed; new operations are fenced".into()), ticket_assignment: None,
|
||||
}).map_err(RuntimeRegistryError::into_error)?)
|
||||
}).await,
|
||||
Err(error) => Err(error.into()),
|
||||
};
|
||||
match cancellation {
|
||||
Ok(result) => {
|
||||
evidence.cancellation_accepted =
|
||||
result.state == InternalWorkerOperationState::Accepted;
|
||||
evidence.diagnostics.extend(
|
||||
result
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.map(|diagnostic| diagnostic.message),
|
||||
);
|
||||
}
|
||||
Err(error) => evidence.diagnostics.push(format!(
|
||||
"Runtime cancellation acceptance unknown or rejected: {}",
|
||||
error.error
|
||||
)),
|
||||
}
|
||||
match close_worker_workdir_sessions(&api.workdir_sessions, &worker).await {
|
||||
Ok(()) => evidence.backend_command_sessions_closed = true,
|
||||
Err(error) => evidence
|
||||
.diagnostics
|
||||
.push(format!("Backend command cleanup not confirmed: {error}")),
|
||||
}
|
||||
results.push(evidence);
|
||||
}
|
||||
results
|
||||
}
|
||||
|
||||
pub(super) fn administration_actor(
|
||||
context: &server_api::ServerRequestContext,
|
||||
) -> ApiResult<&RequestActor> {
|
||||
if context.runtime_source.is_some() || context.worker_source.is_some() {
|
||||
return Err(denied(
|
||||
"Subject permission administration is not a Worker capability",
|
||||
));
|
||||
}
|
||||
context
|
||||
.actor
|
||||
.as_ref()
|
||||
.ok_or_else(|| denied("Subject permission administration requires an authenticated owner"))
|
||||
}
|
||||
@@ -105,6 +105,7 @@ async fn cleanup_retention_failures_preserve_cause_on_execution_and_recovery() {
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -279,6 +280,7 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() {
|
||||
resolved_memory_settings: Some(test_worker_memory_settings()),
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
@@ -471,11 +473,10 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() {
|
||||
));
|
||||
// Seed an already registered command, without launching a real process.
|
||||
let provider_handle = CommandHandle("logging-command".to_string());
|
||||
session
|
||||
.active_commands
|
||||
.lock()
|
||||
.await
|
||||
.insert(provider_handle.0.clone(), provider_handle.clone());
|
||||
session.active_commands.lock().await.insert(
|
||||
provider_handle.0.clone(),
|
||||
Arc::new(ExternalProviderCommandState::new(provider_handle.clone())),
|
||||
);
|
||||
let handle = fixture
|
||||
.api
|
||||
.workdir_sessions
|
||||
@@ -682,13 +683,24 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t
|
||||
let mut fixture = manual_worker_assignment_fixture().await;
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let provider_root = root.path().to_path_buf();
|
||||
// Use an attachment-relative path so Host resource validation succeeds;
|
||||
// the provider's narrower scope still owns this read refusal.
|
||||
let provider_scope = manifest::Scope::writable(&provider_root)
|
||||
.unwrap()
|
||||
.with_added_deny_rules([manifest::ScopeRule {
|
||||
target: provider_root.join("private"),
|
||||
permission: manifest::Permission::Read,
|
||||
recursive: true,
|
||||
symlink_policy: manifest::SymlinkPolicy::Resolved,
|
||||
}])
|
||||
.unwrap();
|
||||
// Fresh sessions exercise the real opening/broker/dispatch path on each request.
|
||||
fixture.runtime.workdir_session_factory = Some(Arc::new(move |id| {
|
||||
Ok(Arc::new(workdir::LocalWorkdirSession::materialized_bound(
|
||||
workdir::Workdir::new(id),
|
||||
provider_root.clone(),
|
||||
provider_root.clone(),
|
||||
manifest::SharedScope::new(manifest::Scope::writable(&provider_root).unwrap()),
|
||||
manifest::SharedScope::new(provider_scope.clone()),
|
||||
Caps::READ_ONLY,
|
||||
)) as WorkdirSessionHandle)
|
||||
}));
|
||||
@@ -708,7 +720,7 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t
|
||||
);
|
||||
let cases = [
|
||||
(
|
||||
json!({"operation": "read", "request": {"path": "/host/private/secret.txt", "offset": 0, "limit": 10, "max_bytes": 1024}}),
|
||||
json!({"operation": "read", "request": {"path": "private/secret.txt", "offset": 0, "limit": 10, "max_bytes": 1024}}),
|
||||
"read",
|
||||
"out_of_scope",
|
||||
Reason::PathOutOfScope,
|
||||
@@ -720,7 +732,7 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t
|
||||
Reason::ScopedCapabilityDenied,
|
||||
),
|
||||
(
|
||||
json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "output_limit": 1024, "cwd": "", "tool_call_id": secret}}),
|
||||
json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "transport_byte_budget": 1048576, "cwd": "", "tool_call_id": secret}}),
|
||||
"command_start",
|
||||
"denied",
|
||||
Reason::ScopedCapabilityDenied,
|
||||
@@ -942,8 +954,8 @@ async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatc
|
||||
serde_json::to_vec(&json!({
|
||||
"target_workdir": "checkout",
|
||||
"operation": {"operation": "command_start", "request": {
|
||||
"command": "body-secret", "timeout_secs": 1, "output_limit": 1024,
|
||||
"cwd": "", "spill_dir": null, "tool_call_id": "body-secret"
|
||||
"command": "body-secret", "timeout_secs": 1, "transport_byte_budget": 1048576,
|
||||
"cwd": "", "tool_call_id": "body-secret"
|
||||
}}
|
||||
}))
|
||||
.unwrap(),
|
||||
@@ -1071,8 +1083,8 @@ async fn saturated_host_before_command_start_returns_availability_without_a_prov
|
||||
serde_json::to_vec(&json!({
|
||||
"target_workdir": "checkout",
|
||||
"operation": {"operation": "command_start", "request": {
|
||||
"command": "not-dispatched", "timeout_secs": 1, "output_limit": 1024,
|
||||
"cwd": "", "spill_dir": null, "tool_call_id": "not-dispatched"
|
||||
"command": "not-dispatched", "timeout_secs": 1, "transport_byte_budget": 1048576,
|
||||
"cwd": "", "tool_call_id": "not-dispatched"
|
||||
}}
|
||||
}))
|
||||
.unwrap(),
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! Provider-bridge lifecycle tests. The production bridge runs against a controlled
|
||||
//! provider boundary; the websocket/CLI-pump test in the parent module covers real byte transport.
|
||||
use super::*;
|
||||
use workdir::WorkdirSession;
|
||||
|
||||
fn command_fixture() -> (
|
||||
Arc<ExternalProviderWorkdirSession>,
|
||||
tokio::sync::mpsc::Receiver<ExternalProviderCommand>,
|
||||
) {
|
||||
let (sender, receiver) = tokio::sync::mpsc::channel(16);
|
||||
let session = Arc::new(ExternalProviderWorkdirSession::new(
|
||||
Arc::new(ExternalProviderConnection {
|
||||
grant_id: "command-grant".into(),
|
||||
workdir_id: "command-workdir".into(),
|
||||
provider_instance_id: "command-provider".into(),
|
||||
generation: 1,
|
||||
expires_at: None,
|
||||
capabilities: workdir::WorkdirSessionCapabilities::COMMAND_ONLY,
|
||||
admission: Arc::new(tokio::sync::Semaphore::new(1)),
|
||||
shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
|
||||
sender,
|
||||
}),
|
||||
None,
|
||||
));
|
||||
(session, receiver)
|
||||
}
|
||||
|
||||
fn page(cursor: u64, bytes: &[u8], terminal: bool) -> workdir::CommandOutput {
|
||||
workdir::CommandOutput {
|
||||
chunks: if bytes.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![workdir::CommandByteChunk {
|
||||
sequence: cursor,
|
||||
stream: workdir::CommandStream::Stdout,
|
||||
offset: cursor,
|
||||
bytes: bytes.to_vec(),
|
||||
}]
|
||||
},
|
||||
next_cursor: cursor + bytes.len() as u64,
|
||||
terminal: terminal.then_some(workdir::CommandTerminal {
|
||||
status: workdir::CommandStatus::Cancelled,
|
||||
exit_code: None,
|
||||
stdout_bytes: cursor + bytes.len() as u64,
|
||||
stderr_bytes: 0,
|
||||
stdout_delivered_bytes: cursor + bytes.len() as u64,
|
||||
stderr_delivered_bytes: 0,
|
||||
truncation_reason: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
async fn receive(
|
||||
receiver: &mut tokio::sync::mpsc::Receiver<ExternalProviderCommand>,
|
||||
) -> (
|
||||
WorkdirSessionOperation,
|
||||
tokio::sync::oneshot::Sender<
|
||||
std::result::Result<WorkdirSessionOperationResult, WorkdirTransportError>,
|
||||
>,
|
||||
) {
|
||||
let command = tokio::time::timeout(std::time::Duration::from_secs(2), receiver.recv())
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let ExternalProviderCommand::Operation {
|
||||
operation,
|
||||
response,
|
||||
..
|
||||
} = command
|
||||
else {
|
||||
panic!("expected provider operation")
|
||||
};
|
||||
(operation, response)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn close_cancels_waiting_reader_then_drains_from_latest_cursor() {
|
||||
let (session, mut receiver) = command_fixture();
|
||||
session.active_commands.lock().await.insert(
|
||||
"external".into(),
|
||||
Arc::new(ExternalProviderCommandState::new(CommandHandle(
|
||||
"provider".into(),
|
||||
))),
|
||||
);
|
||||
let reader = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: CommandHandle("external".into()),
|
||||
cursor: 0,
|
||||
limit: 3,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let (_, response) = receive(&mut receiver).await;
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||
0, b"one", false,
|
||||
))))
|
||||
.unwrap();
|
||||
assert_eq!(reader.await.unwrap().unwrap().next_cursor, 3);
|
||||
|
||||
let reader = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: CommandHandle("external".into()),
|
||||
cursor: 3,
|
||||
limit: 3,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let (_, pending_read) = receive(&mut receiver).await;
|
||||
let closer = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move { session.close().await })
|
||||
};
|
||||
let (operation, response) = receive(&mut receiver).await;
|
||||
assert!(
|
||||
matches!(operation, WorkdirSessionOperation::CommandCancel(ref handle) if handle.0 == "provider")
|
||||
);
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandCancel))
|
||||
.unwrap();
|
||||
pending_read
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||
3, b"two", false,
|
||||
))))
|
||||
.unwrap();
|
||||
assert_eq!(reader.await.unwrap().unwrap().next_cursor, 6);
|
||||
|
||||
for (cursor, bytes, terminal) in [(6, &b"end"[..], false), (9, &b""[..], true)] {
|
||||
let (operation, response) = receive(&mut receiver).await;
|
||||
assert!(
|
||||
matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == cursor && request.wait)
|
||||
);
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||
cursor, bytes, terminal,
|
||||
))))
|
||||
.unwrap();
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_secs(2), closer)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_output_retains_same_binding_for_current_page_replay() {
|
||||
let (session, mut receiver) = command_fixture();
|
||||
session.active_commands.lock().await.insert(
|
||||
"external".into(),
|
||||
Arc::new(ExternalProviderCommandState::new(CommandHandle(
|
||||
"provider".into(),
|
||||
))),
|
||||
);
|
||||
for _ in 0..2 {
|
||||
let reader = {
|
||||
let session = session.clone();
|
||||
tokio::spawn(async move {
|
||||
session
|
||||
.command_output(workdir::CommandOutputRequest {
|
||||
handle: CommandHandle("external".into()),
|
||||
cursor: 0,
|
||||
limit: 3,
|
||||
wait: true,
|
||||
})
|
||||
.await
|
||||
})
|
||||
};
|
||||
let (operation, response) = receive(&mut receiver).await;
|
||||
assert!(
|
||||
matches!(operation, WorkdirSessionOperation::CommandOutput(ref request) if request.handle.0 == "provider" && request.cursor == 0)
|
||||
);
|
||||
response
|
||||
.send(Ok(WorkdirSessionOperationResult::CommandOutput(page(
|
||||
0, b"end", true,
|
||||
))))
|
||||
.unwrap();
|
||||
assert_eq!(reader.await.unwrap().unwrap(), page(0, b"end", true));
|
||||
}
|
||||
assert_eq!(session.active_commands.lock().await.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_registry_retention_is_bounded_and_excludes_active_commands() {
|
||||
let (source, _) = command_fixture();
|
||||
let worker = RuntimeWorkerRef::new("runtime", "worker");
|
||||
let mut registry = WorkdirSessionRegistry::default();
|
||||
let active = registry.register_command(
|
||||
worker.clone(),
|
||||
"checkout".into(),
|
||||
source.clone(),
|
||||
CommandHandle("active".into()),
|
||||
);
|
||||
let mut last = None;
|
||||
for index in 0..MAX_RETAINED_TERMINAL_COMMANDS + 4 {
|
||||
let handle = registry.register_command(
|
||||
worker.clone(),
|
||||
"checkout".into(),
|
||||
source.clone(),
|
||||
CommandHandle(format!("terminal-{index}")),
|
||||
);
|
||||
registry.mark_terminal(&worker, &handle);
|
||||
last = Some(handle);
|
||||
}
|
||||
assert_eq!(registry.active_command_count(&worker), 1);
|
||||
assert_eq!(registry.commands.len(), MAX_RETAINED_TERMINAL_COMMANDS + 1);
|
||||
assert!(registry.command(&worker, "checkout", &active).is_some());
|
||||
assert!(
|
||||
registry
|
||||
.command(&worker, "checkout", &last.unwrap())
|
||||
.is_some()
|
||||
);
|
||||
}
|
||||
@@ -325,6 +325,7 @@ async fn workers(api: &WorkspaceApi) -> (WorkdirlessFixtureRuntime, Vec<RuntimeW
|
||||
workdir_attachments: vec![],
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -777,6 +778,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse
|
||||
configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id);
|
||||
let routes = [
|
||||
format!("{}/root", base()),
|
||||
format!("{}/capabilities", base()),
|
||||
format!(
|
||||
"{}/metadata?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}",
|
||||
base()
|
||||
@@ -1007,3 +1009,106 @@ async fn drive_download_stream_expires_instead_of_mixing_last_mutation_ids() {
|
||||
"next chunk must expire, not deliver generation b"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn drive_capabilities_expose_only_authenticated_current_caller_ceiling() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let mut api = test_api(temp.path()).await;
|
||||
let owner = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID);
|
||||
let (_runtime, workers) = workers(&api).await;
|
||||
let identity = RuntimeIdentityMaterial::generate(&workers[0].runtime_id).unwrap();
|
||||
configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id);
|
||||
let path = format!("{}/capabilities", base());
|
||||
let unsigned = build_router(api.clone())
|
||||
.oneshot(Request::builder().uri(&path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
unsigned.status(),
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
|
||||
));
|
||||
signed(
|
||||
&api,
|
||||
&identity,
|
||||
None,
|
||||
"GET",
|
||||
&path,
|
||||
None,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let mut grants = vec![];
|
||||
for (worker, access, write) in [
|
||||
(&workers[0], "read_only", false),
|
||||
(&workers[1], "read_write", true),
|
||||
] {
|
||||
grants.push(user_call(&api,&owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":worker.runtime_id,"worker_id":worker.worker_id,"access":access})),StatusCode::OK).await);
|
||||
assert_eq!(
|
||||
signed(
|
||||
&api,
|
||||
&identity,
|
||||
Some(&worker.worker_id),
|
||||
"GET",
|
||||
&path,
|
||||
None,
|
||||
StatusCode::OK
|
||||
)
|
||||
.await,
|
||||
json!({"write":write})
|
||||
);
|
||||
}
|
||||
// Altering a signed source cannot acquire the other Worker's larger ceiling.
|
||||
let mut forged =
|
||||
runtime_source_request(&identity, Some(&workers[0].worker_id), "GET", &path, vec![]);
|
||||
forged
|
||||
.headers_mut()
|
||||
.insert("x-yoi-worker-id", workers[1].worker_id.parse().unwrap());
|
||||
let response = build_router(api.clone()).oneshot(forged).await.unwrap();
|
||||
assert!(matches!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
|
||||
));
|
||||
let foreign = path.replace(&format!("/w/{TEST_WORKSPACE_ID}/"), "/w/other-workspace/");
|
||||
let response = build_router(api.clone())
|
||||
.oneshot(runtime_source_request(
|
||||
&identity,
|
||||
Some(&workers[1].worker_id),
|
||||
"GET",
|
||||
&foreign,
|
||||
vec![],
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!response.status().is_success());
|
||||
signed(
|
||||
&api,
|
||||
&identity,
|
||||
Some("nonexistent-worker"),
|
||||
"GET",
|
||||
&path,
|
||||
None,
|
||||
StatusCode::UNAUTHORIZED,
|
||||
)
|
||||
.await;
|
||||
for (worker, grant) in workers.iter().zip(&grants) {
|
||||
user_call(
|
||||
&api,
|
||||
&owner,
|
||||
"DELETE",
|
||||
&format!("{}/grants/{}", base(), grant["grant_id"].as_str().unwrap()),
|
||||
None,
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
signed(
|
||||
&api,
|
||||
&identity,
|
||||
Some(&worker.worker_id),
|
||||
"GET",
|
||||
&path,
|
||||
None,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,6 +183,7 @@ impl RealDriveFixture {
|
||||
workdir_attachments: vec![],
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! Restore entry -> real SQLite attachment registry -> embedded Runtime restore
|
||||
//! coordination. Only execution is deterministic; Workdir providers are absent.
|
||||
//! This suite covers restore preparation, not HTTP authentication or model turns.
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_keeps_offline_external_and_unmaterialized_repository_attachments() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
init_clean_git_workspace(temp.path());
|
||||
let (api, execution) = test_api_with_recording_backend(temp.path()).await;
|
||||
execution.accept_restores();
|
||||
let Json(created) = create_workspace_worker(
|
||||
State(api.clone()),
|
||||
HeaderMap::new(),
|
||||
Json(CreateWorkspaceWorkerRequest {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(),
|
||||
display_name: "Restore without providers".into(),
|
||||
singleton_key: None,
|
||||
profile: Some("builtin:coder".into()),
|
||||
ticket_assignment: None,
|
||||
initial_submit: Vec::new(),
|
||||
workdir_attachments: Vec::new(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let worker = RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id);
|
||||
let grant = ExternalWorkdirGrantRecord {
|
||||
grant_id: "restore-external-grant".into(),
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
workdir_id: "restore-external".into(),
|
||||
provider_instance_id: "offline-provider".into(),
|
||||
display_name: "Offline external".into(),
|
||||
permissions: "read_only".into(),
|
||||
created_by: test_browser_request_actor().account_id,
|
||||
created_at: TEST_CREATED_AT.into(),
|
||||
expires_at: None,
|
||||
generation: 1,
|
||||
status: "pending".into(),
|
||||
updated_at: TEST_CREATED_AT.into(),
|
||||
};
|
||||
for (alias, workdir_id, source) in [
|
||||
(
|
||||
"external",
|
||||
grant.workdir_id.as_str(),
|
||||
WorkdirRegistrySource::ExternalGrant {
|
||||
grant_id: grant.grant_id.clone(),
|
||||
},
|
||||
),
|
||||
(
|
||||
"repository",
|
||||
"restore-missing-repository",
|
||||
WorkdirRegistrySource::Repository {
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(),
|
||||
repository_id: test_repository_id(&api),
|
||||
},
|
||||
),
|
||||
] {
|
||||
let record = WorkdirRegistryRecord {
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
workdir_id: workdir_id.into(),
|
||||
display_name: Some(alias.into()),
|
||||
source,
|
||||
creation_selector: None,
|
||||
creation_ref: None,
|
||||
creation_tree: None,
|
||||
current_selector: None,
|
||||
current_ref: None,
|
||||
current_tree: None,
|
||||
observed_at_epoch_seconds: None,
|
||||
materialization_status: "not_found".into(),
|
||||
cleanliness: "unknown".into(),
|
||||
created_at: TEST_CREATED_AT.into(),
|
||||
updated_at: TEST_CREATED_AT.into(),
|
||||
};
|
||||
if alias == "external" {
|
||||
api.store
|
||||
.create_external_workdir_grant(&grant, &record)
|
||||
.unwrap();
|
||||
assert!(
|
||||
api.store
|
||||
.activate_external_workdir_grant(
|
||||
TEST_WORKSPACE_ID,
|
||||
&grant.grant_id,
|
||||
&grant.provider_instance_id,
|
||||
1,
|
||||
TEST_CREATED_AT,
|
||||
)
|
||||
.unwrap()
|
||||
);
|
||||
} else {
|
||||
api.store.upsert_workdir_registry(&record).unwrap();
|
||||
}
|
||||
api.store
|
||||
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||
connection_id: String::new(),
|
||||
workspace_id: TEST_WORKSPACE_ID.into(),
|
||||
worker: worker.clone(),
|
||||
workdir_id: workdir_id.into(),
|
||||
alias: alias.into(),
|
||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||
linked_at: TEST_CREATED_AT.into(),
|
||||
unlinked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
|
||||
.unwrap();
|
||||
api.runtime
|
||||
.stop_worker(
|
||||
&worker,
|
||||
WorkerLifecycleRequest {
|
||||
reason: None,
|
||||
ticket_assignment: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let result = api
|
||||
.restore_workspace_worker(
|
||||
&worker,
|
||||
server_api::WorkerRestoreRequest {
|
||||
expected_observation_token: api
|
||||
.runtime
|
||||
.worker(&worker)
|
||||
.unwrap()
|
||||
.restore_observation_token
|
||||
.unwrap(),
|
||||
request_id: uuid::Uuid::now_v7().to_string(),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.state, server_api::WorkerRestoreState::Accepted);
|
||||
let attachments = restore_logical_workdir_attachments(&api, &worker).unwrap();
|
||||
assert_eq!(attachments.len(), 2);
|
||||
assert!(
|
||||
attachments
|
||||
.iter()
|
||||
.any(|attachment| attachment.alias.as_str() == "external"
|
||||
&& attachment.working_directory_id == grant.workdir_id)
|
||||
);
|
||||
assert!(
|
||||
attachments
|
||||
.iter()
|
||||
.any(|attachment| attachment.alias.as_str() == "repository"
|
||||
&& attachment.working_directory_id == "restore-missing-repository")
|
||||
);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
|
||||
.unwrap(),
|
||||
links
|
||||
);
|
||||
let lock = current_worker_session_lock(&api, &worker);
|
||||
let guard = Arc::new(lock.lock_owned().await);
|
||||
for link in &links {
|
||||
let error = open_current_worker_workdir_session_locked(&api, &worker, link, &guard)
|
||||
.await
|
||||
.unwrap_err();
|
||||
match link.alias.as_str() {
|
||||
"external" => assert!(matches!(
|
||||
error,
|
||||
Error::RuntimeOperationFailed { code, .. }
|
||||
if code == "external_workdir_provider_offline"
|
||||
)),
|
||||
"repository" => assert!(matches!(
|
||||
error,
|
||||
Error::WorkdirSessionOpenFailed { transport, .. }
|
||||
if transport.code == workdir::http::WorkdirTransportErrorCode::Unavailable
|
||||
)),
|
||||
alias => panic!("unexpected attachment: {alias}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
api.store
|
||||
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
|
||||
.unwrap(),
|
||||
links
|
||||
);
|
||||
api.runtime
|
||||
.stop_worker(
|
||||
&worker,
|
||||
WorkerLifecycleRequest {
|
||||
reason: None,
|
||||
ticket_assignment: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
@@ -156,6 +156,7 @@ async fn spawn_pair_member(api: &WorkspaceApi, name: &str) -> RuntimeWorkerRef {
|
||||
workdir_attachments: vec![],
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -527,6 +527,7 @@ async fn subjektiv_job_legacy_cutover_waits_foreground_then_removes_only_owned_c
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: vec![],
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
},
|
||||
)
|
||||
.unwrap()
|
||||
|
||||
@@ -260,7 +260,7 @@ async fn connected_create(api: &WorkspaceApi, subject: &str, name: &str) -> Runt
|
||||
workdir_attachments: vec![],
|
||||
feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest {
|
||||
subjektiv: Some(server_api::SubjektivWorkerConnectionRequest { subject_id: subject.into() }),
|
||||
}, control_operation_id: None,
|
||||
}, control_operation_id: None, execution_grant: None,
|
||||
}),
|
||||
).await.unwrap();
|
||||
RuntimeWorkerRef::new(created.runtime_id, created.worker_id)
|
||||
@@ -386,6 +386,56 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup()
|
||||
let subject = store
|
||||
.create_subject(crate::subjektiv::SubjectRole::new("connected-handoff").unwrap())
|
||||
.unwrap();
|
||||
let owner =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "connected-journal-owner").await;
|
||||
// This fixture exercises Journal continuity, not unset-policy refusal. Assign
|
||||
// the companion's exact activation keys through authenticated administration;
|
||||
// no filesystem, shell or delegation permission is granted to its body.
|
||||
let update = server_api::authorization::SubjectPermissionsUpdate {
|
||||
operation_id: "connected-journal-activation".into(),
|
||||
expected_change_id: None,
|
||||
execution: [
|
||||
"task",
|
||||
"subjektiv",
|
||||
"web",
|
||||
"image",
|
||||
"sub_worker",
|
||||
"worker",
|
||||
"workspace_worker_discovery",
|
||||
"objective",
|
||||
"manage_workdir",
|
||||
"workdir_catalog",
|
||||
"drive",
|
||||
"ticket",
|
||||
]
|
||||
.into_iter()
|
||||
.map(|name| server_api::authorization::ScopedPermission {
|
||||
operation: name.into(),
|
||||
resource: server_api::authorization::PermissionResource::Feature,
|
||||
})
|
||||
.collect(),
|
||||
delegation: Vec::new(),
|
||||
};
|
||||
let response = build_router(api.clone())
|
||||
.oneshot(
|
||||
axum::http::Request::builder()
|
||||
.method("POST")
|
||||
.uri(format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions",
|
||||
subject.id
|
||||
))
|
||||
.header("authorization", format!("Bearer {owner}"))
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from(serde_json::to_vec(&update).unwrap()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = response.status();
|
||||
let body = to_bytes(response.into_body(), 2 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
|
||||
let old = connected_create(&api, &subject.id, "connected-old-body").await;
|
||||
let old_attr = store
|
||||
.subject_session_attributions(&subject.id)
|
||||
@@ -547,30 +597,40 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup()
|
||||
let segments = session.list_segments(session_id).unwrap();
|
||||
assert_eq!(segments.len(), 1);
|
||||
let entries = session.read_all_read_only(session_id, segments[0]).unwrap();
|
||||
#[derive(serde::Deserialize)]
|
||||
struct SavedJournal {
|
||||
snapshot: serde_json::Value,
|
||||
snapshot_sha256: String,
|
||||
prompt_provenance: session_store::PromptRenderProvenance,
|
||||
}
|
||||
let startup: Vec<_> = entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, log)| match log {
|
||||
session_store::LogEntry::AnnotatedSystemItem { entry, .. } => match &entry.item {
|
||||
session_store::SystemItem::JournalStartup {
|
||||
snapshot,
|
||||
snapshot_sha256,
|
||||
body,
|
||||
prompt_provenance,
|
||||
} => Some((index, snapshot, snapshot_sha256, body, prompt_provenance)),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
.filter_map(|(index, log)| {
|
||||
let session_store::LogEntry::AnnotatedSystemItem { entry, .. } = log else {
|
||||
return None;
|
||||
};
|
||||
let payload = entry
|
||||
.metadata
|
||||
.input_metadata
|
||||
.as_ref()?
|
||||
.feature_payload()?
|
||||
.decode::<SavedJournal>("journal:startup", 1)
|
||||
.ok()?;
|
||||
Some((index, payload, entry.item.history_text()))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(startup.len(), 1, "startup must be durably committed once");
|
||||
let (startup_index, snapshot, digest, body, provenance) = startup[0];
|
||||
let (startup_index, payload, body) = &startup[0];
|
||||
let snapshot = &payload.snapshot;
|
||||
let digest = &payload.snapshot_sha256;
|
||||
let provenance = &payload.prompt_provenance;
|
||||
let first_input = entries
|
||||
.iter()
|
||||
.position(|entry| matches!(entry, session_store::LogEntry::AnnotatedUserInput { .. }))
|
||||
.expect("new body persisted its initial human input");
|
||||
assert!(
|
||||
startup_index < first_input,
|
||||
*startup_index < first_input,
|
||||
"startup reference must be persisted before the first human input/model request"
|
||||
);
|
||||
assert_eq!(snapshot["subject_id"], subject.id);
|
||||
|
||||
@@ -479,6 +479,7 @@ fn seed_session(
|
||||
vec![session_store::LoggedHistoryEntry {
|
||||
item,
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
input_metadata: None,
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId("read-source-original".into()),
|
||||
origin,
|
||||
derivation: None,
|
||||
@@ -1686,6 +1687,7 @@ impl SubjectRemovalFixture {
|
||||
}),
|
||||
},
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -1706,6 +1708,7 @@ impl SubjectRemovalFixture {
|
||||
.map(|text| session_store::LoggedHistoryEntry {
|
||||
item: message(text),
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
input_metadata: None,
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId(text.into()),
|
||||
origin: session_store::LoggedSessionHistoryOrigin::LocalHumanInput,
|
||||
derivation: None,
|
||||
|
||||
@@ -67,6 +67,7 @@ impl Fixture {
|
||||
content: vec![session_store::LoggedContentPart::Text { text: text.into() }],
|
||||
},
|
||||
metadata: session_store::LoggedSessionHistoryMetadata {
|
||||
input_metadata: None,
|
||||
entry_id: session_store::LoggedSessionHistoryEntryId(format!("original-{index}")),
|
||||
origin: session_store::LoggedSessionHistoryOrigin::LocalHumanInput,
|
||||
derivation: None,
|
||||
|
||||
@@ -1032,6 +1032,7 @@ async fn read_only_ticket_targets_allow_extra_external_grants_without_widening_c
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
};
|
||||
api.validate_worker_spawn_repository_scope(EMBEDDED_WORKER_RUNTIME_ID, &mut request)
|
||||
.unwrap();
|
||||
@@ -1580,6 +1581,7 @@ async fn mr_open_retains_source_before_first_review_and_observed_result() {
|
||||
}],
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -200,6 +200,7 @@ impl Fixture {
|
||||
workdir_attachments: vec![],
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -517,7 +518,7 @@ async fn user_uploaded_markdown_roundtrips_through_workdirless_tools_and_native_
|
||||
read["entry"]["metadata"]["last_mutation_id"],
|
||||
uploaded["entry"]["last_mutation_id"]
|
||||
);
|
||||
value(native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":updated_text}})).await.unwrap())
|
||||
value(native_tool(&tools, "Invoke", json!({"path":path,"interface":file_write_reference(&inspected),"operation":"write","arguments":{"content":updated_text}})).await.unwrap())["metadata"].clone()
|
||||
} else {
|
||||
let read = value(
|
||||
tool(&f.features[0], "DriveRead", json!({"entry":id}))
|
||||
@@ -733,6 +734,19 @@ async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replace
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(f.api.runtime.worker(old).is_err());
|
||||
let client = f.clients[0].clone();
|
||||
let capability = tokio::task::spawn_blocking(move || {
|
||||
client.request(
|
||||
WorkspaceRequestMethod::Get,
|
||||
format!("{}/capabilities", base()),
|
||||
vec![],
|
||||
65536,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(!(200..300).contains(&capability.status));
|
||||
assert!(
|
||||
f.api
|
||||
.store
|
||||
@@ -754,6 +768,7 @@ async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replace
|
||||
workdir_attachments: vec![],
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -1571,11 +1586,30 @@ impl agen::llm_client::LlmClient for NoLlm {
|
||||
panic!("native tool fixture must not call an LLM")
|
||||
}
|
||||
}
|
||||
fn file_write_reference(observed: &Value) -> Value {
|
||||
observed["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
|
||||
.expect("write capability")["reference"]
|
||||
.clone()
|
||||
}
|
||||
fn native_tools(
|
||||
feature: &DriveFeature,
|
||||
) -> std::collections::BTreeMap<String, Arc<dyn agen::tool::Tool>> {
|
||||
native_tools_with_checkouts(feature, None)
|
||||
}
|
||||
fn native_tools_with_checkouts(
|
||||
feature: &DriveFeature,
|
||||
router: Option<Arc<WorkdirSessionRouter>>,
|
||||
) -> std::collections::BTreeMap<String, Arc<dyn agen::tool::Tool>> {
|
||||
let mut mounts = worker::wip::WipMountRegistry::new();
|
||||
worker::feature::builtin::drive::wip::mount_drive_wip(&mut mounts, feature).unwrap();
|
||||
if let Some(router) = router {
|
||||
worker::checkout::mount_checkouts(&mut mounts, router, tools::Tracker::new(), None)
|
||||
.unwrap();
|
||||
}
|
||||
let mut engine = agen::Engine::<_, agen::state::Mutable, ()>::new_annotated(NoLlm);
|
||||
engine.register_tools(
|
||||
feature
|
||||
@@ -1651,7 +1685,14 @@ async fn native_tree_does_not_index_entries_but_direct_inspect_and_invoke_use_cu
|
||||
);
|
||||
assert_eq!(read["text"], "native content");
|
||||
let denied_write = native_tool(&tools, "Invoke", json!({"path":path,"interface":reference,"operation":"write","arguments":{"content":"lost"}})).await;
|
||||
denied(denied_write);
|
||||
assert!(denied_write.is_err());
|
||||
assert!(
|
||||
!inspected["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
|
||||
);
|
||||
f.revoke(1).await;
|
||||
assert!(
|
||||
native_tool(
|
||||
@@ -1705,12 +1746,12 @@ async fn native_create_and_write_use_client_managed_committed_requests_and_rejec
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":observed_first["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"committed"}})).await.unwrap());
|
||||
let updated = value(native_tool(&first, "Invoke", json!({"path":path,"interface":file_write_reference(&observed_first),"operation":"write","arguments":{"content":"committed"}})).await.unwrap())["metadata"].clone();
|
||||
assert_ne!(
|
||||
updated["entry"]["metadata"]["last_mutation_id"],
|
||||
created["entry"]["metadata"]["last_mutation_id"]
|
||||
);
|
||||
let error = native_tool(&second, "Invoke", json!({"path":path,"interface":observed_second["interfaces"][0]["reference"],"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err();
|
||||
let error = native_tool(&second, "Invoke", json!({"path":path,"interface":file_write_reference(&observed_second),"operation":"write","arguments":{"content":"lost"}})).await.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().to_lowercase().contains("validator"),
|
||||
"stale native mutation must report validator conflict: {error:?}"
|
||||
@@ -1833,3 +1874,143 @@ async fn image_attachment_retains_original_bytes_after_drive_update_and_revocati
|
||||
"history attachment must not fetch latest Drive content"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn common_file_inspect_invoke_crosses_drive_and_checkout_without_transferring_authority() {
|
||||
let f = Fixture::new(["read_write", "read_only"]).await;
|
||||
let created = f.create("common.md", "first\n雪 needle\nlast\n").await;
|
||||
let path = created["entry"]["path"].as_str().unwrap();
|
||||
let checkout = f._temp.path().join("checkout");
|
||||
fs::create_dir(&checkout).unwrap();
|
||||
fs::write(checkout.join("file"), "first\n雪 needle\nlast\n").unwrap();
|
||||
let router = Arc::new(WorkdirSessionRouter::new());
|
||||
router
|
||||
.attach(
|
||||
workdir::WorkdirAttachmentAlias::new("main").unwrap(),
|
||||
Arc::new(workdir::LocalWorkdirSession::materialized(
|
||||
checkout.clone(),
|
||||
checkout.clone(),
|
||||
manifest::SharedScope::new(manifest::Scope::writable(&checkout).unwrap()),
|
||||
workdir::WorkdirSessionCapabilities::READ_WRITE,
|
||||
)),
|
||||
)
|
||||
.unwrap();
|
||||
let tools = native_tools_with_checkouts(&f.features[0], Some(router));
|
||||
let drive = value(
|
||||
native_tool(&tools, "Inspect", json!({"path":path}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
let local = value(
|
||||
native_tool(&tools, "Inspect", json!({"path":"/checkouts/main/file"}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
for name in [
|
||||
"yoi.file/read-lines/v1",
|
||||
"yoi.file/search-text/v1",
|
||||
"yoi.file/edit-text/v1",
|
||||
"yoi.file/write-text/v1",
|
||||
] {
|
||||
let selected = drive["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i["reference"]["name"] == name)
|
||||
.unwrap();
|
||||
let reference = &selected["reference"];
|
||||
assert_eq!(reference["scope"], "/");
|
||||
assert!(
|
||||
local["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i["reference"] == *reference)
|
||||
);
|
||||
let signature = value(
|
||||
native_tool(&tools, "Inspect", json!({"path":selected["path"]}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(
|
||||
signature["interface_signature"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(name)
|
||||
);
|
||||
let (operation, arguments) = match name {
|
||||
"yoi.file/read-lines/v1" => ("read", json!({"offset":1,"limit":1})),
|
||||
"yoi.file/search-text/v1" => ("search", json!({"pattern":"needle","limit":2})),
|
||||
"yoi.file/edit-text/v1" => {
|
||||
("edit", json!({"old_string":"needle","new_string":"found"}))
|
||||
}
|
||||
_ => ("write", json!({"content":"saved\n"})),
|
||||
};
|
||||
let mut results = vec![];
|
||||
for target in [path, "/checkouts/main/file"] {
|
||||
results.push(value(native_tool(&tools,"Invoke",json!({"path":target,"interface":reference,"operation":operation,"arguments":arguments})).await.unwrap()));
|
||||
}
|
||||
if matches!(operation, "read" | "search") {
|
||||
assert_eq!(results[0], results[1]);
|
||||
} else {
|
||||
assert!(results.iter().all(|v| v.get("metadata").is_some()));
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
fs::read_to_string(checkout.join("file")).unwrap(),
|
||||
"saved\n"
|
||||
);
|
||||
assert_eq!(
|
||||
value(
|
||||
tool(
|
||||
&f.features[1],
|
||||
"DriveRead",
|
||||
json!({"entry":entry(&created)})
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
)["text"],
|
||||
"saved\n"
|
||||
);
|
||||
// A capability query is not a grant: drop the writer to readonly after the common write was cached.
|
||||
f.revoke(0).await;
|
||||
owner_call(&f.api,&f.owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":f.clients[0].worker.runtime_id,"worker_id":f.clients[0].worker.worker_id,"access":"read_only"}))).await;
|
||||
assert!(native_tool(&tools,"Invoke",json!({"path":path,"interface":file_write_reference(&drive),"operation":"write","arguments":{"content":"lost"}})).await.is_err());
|
||||
let refreshed = value(
|
||||
native_tool(&tools, "Inspect", json!({"path":path,"refresh":true}))
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
assert!(
|
||||
!refreshed["interfaces"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|i| i["reference"]["name"] == "yoi.file/write-text/v1")
|
||||
);
|
||||
let common_read = json!({"scope":"/","name":"yoi.file/read-lines/v1"});
|
||||
assert_eq!(
|
||||
value(
|
||||
native_tool(
|
||||
&tools,
|
||||
"Invoke",
|
||||
json!({"path":path,"interface":common_read,"operation":"read","arguments":{}})
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
)["content"],
|
||||
" 1\tsaved\n"
|
||||
);
|
||||
assert_eq!(
|
||||
value(
|
||||
tool(
|
||||
&f.features[1],
|
||||
"DriveRead",
|
||||
json!({"entry":entry(&created)})
|
||||
)
|
||||
.await
|
||||
.unwrap()
|
||||
)["text"],
|
||||
"saved\n"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1593,7 +1593,7 @@ async fn restore_guard_workspace_interrupted_preparation_recovers_admitted_snaps
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_workdir_owner() {
|
||||
async fn restore_keeps_cross_runtime_ssh_attachment_without_issuing_repository_access() {
|
||||
let workspace = tempfile::tempdir().unwrap();
|
||||
let mut config = test_server_config(workspace.path());
|
||||
let source = server_api::RepositorySource {
|
||||
@@ -1619,39 +1619,6 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w
|
||||
.stop(&WorkerOperationContext::Backend, operation_lifecycle())
|
||||
.await
|
||||
.unwrap();
|
||||
let original_key = api
|
||||
.repository_secrets
|
||||
.generate_credential(
|
||||
api.workspace_id(),
|
||||
GenerateRepositorySshCredentialRequest {
|
||||
operation_id: "cross-runtime-default-key".into(),
|
||||
credential_id:
|
||||
crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID.into(),
|
||||
name: "Restore default".into(),
|
||||
},
|
||||
"owner-account",
|
||||
)
|
||||
.unwrap();
|
||||
let public_key = api
|
||||
.repository_secrets
|
||||
.credential_public_key(api.workspace_id(), &original_key.credential_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.public_key;
|
||||
api.repository_secrets
|
||||
.put_host_trust(
|
||||
api.workspace_id(),
|
||||
PutRepositorySshHostTrustRequest {
|
||||
operation_id: "cross-runtime-trust".into(),
|
||||
host_trust_id: "cross-runtime-host".into(),
|
||||
hostname: "example.invalid".into(),
|
||||
port: 22,
|
||||
host_key: public_key,
|
||||
expected_fingerprint: None,
|
||||
},
|
||||
"owner-account",
|
||||
)
|
||||
.unwrap();
|
||||
let record = restore_test_workdir(
|
||||
&api,
|
||||
"cross-runtime-restore-workdir",
|
||||
@@ -1663,102 +1630,27 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w
|
||||
peer.set_workdir_repository(&record.workdir_id, &test_repository_id(&api));
|
||||
api.store.upsert_workdir_registry(&record).unwrap();
|
||||
attach_restore_test_workdir(&api, &identity, &record);
|
||||
let links = api
|
||||
.store
|
||||
.list_worker_workdir_links(api.workspace_id(), &identity)
|
||||
.unwrap();
|
||||
let request = restore_request_for(&api, &identity, "cross-runtime-restore-request");
|
||||
assert_eq!(
|
||||
handle
|
||||
.restore(&WorkerOperationContext::Backend, request.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.state,
|
||||
server_api::WorkerRestoreState::ReconciliationRequired
|
||||
);
|
||||
assert!(
|
||||
execution.restore_operations.lock().unwrap().is_empty(),
|
||||
"credential completion must precede execution"
|
||||
);
|
||||
let first = peer.repository_access_requests.lock().unwrap()[0].clone();
|
||||
assert_eq!(
|
||||
first.materialization.runtime_id,
|
||||
WorkdirlessFixtureRuntime::RUNTIME_ID
|
||||
);
|
||||
assert_ne!(first.materialization.runtime_id, identity.runtime_id);
|
||||
// Rotate current host trust while the accepted operation is uncertain. Its
|
||||
// original credential/trust fingerprints, source and binding must remain pinned.
|
||||
let alternate = api
|
||||
.repository_secrets
|
||||
.generate_credential(
|
||||
api.workspace_id(),
|
||||
GenerateRepositorySshCredentialRequest {
|
||||
operation_id: "cross-runtime-alternate-key".into(),
|
||||
credential_id: "cross-runtime-alternate".into(),
|
||||
name: "Alternate".into(),
|
||||
},
|
||||
"owner-account",
|
||||
)
|
||||
.unwrap();
|
||||
let alternate_public = api
|
||||
.repository_secrets
|
||||
.credential_public_key(api.workspace_id(), &alternate.credential_id)
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.public_key;
|
||||
let original_trust_fingerprint = first
|
||||
.materialization
|
||||
.ssh
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.host_key_fingerprint
|
||||
.clone();
|
||||
api.repository_secrets
|
||||
.put_host_trust(
|
||||
api.workspace_id(),
|
||||
PutRepositorySshHostTrustRequest {
|
||||
operation_id: "cross-runtime-trust-rotate".into(),
|
||||
host_trust_id: "cross-runtime-host".into(),
|
||||
hostname: "example.invalid".into(),
|
||||
port: 22,
|
||||
host_key: alternate_public,
|
||||
expected_fingerprint: Some(original_trust_fingerprint),
|
||||
},
|
||||
"owner-account",
|
||||
)
|
||||
.unwrap();
|
||||
*peer.reject_repository_access.lock().unwrap() = false;
|
||||
assert_eq!(
|
||||
handle
|
||||
.restore(&WorkerOperationContext::Backend, request.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.state,
|
||||
server_api::WorkerRestoreState::Accepted
|
||||
);
|
||||
let attempts = peer.repository_access_requests.lock().unwrap();
|
||||
assert_eq!(attempts.len(), 2);
|
||||
let second = &attempts[1];
|
||||
assert_eq!(
|
||||
second.materialization.operation_id,
|
||||
first.materialization.operation_id
|
||||
);
|
||||
let mut normalized = second.clone();
|
||||
let old_ssh = first.materialization.ssh.as_ref().unwrap();
|
||||
let new_ssh = normalized.materialization.ssh.as_mut().unwrap();
|
||||
assert_ne!(new_ssh.secret_resource.nonce, old_ssh.secret_resource.nonce);
|
||||
new_ssh.secret_resource = old_ssh.secret_resource.clone();
|
||||
new_ssh.expires_at_epoch_seconds = old_ssh.expires_at_epoch_seconds;
|
||||
assert_eq!(normalized, first, "only opaque handle and expiry may renew");
|
||||
drop(attempts);
|
||||
assert_eq!(execution.restore_operations.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
handle
|
||||
.restore(&WorkerOperationContext::Backend, request)
|
||||
.await
|
||||
.unwrap()
|
||||
.state,
|
||||
server_api::WorkerRestoreState::Accepted
|
||||
);
|
||||
assert_eq!(
|
||||
peer.repository_access_requests.lock().unwrap().len(),
|
||||
2,
|
||||
"terminal receipt must not renew access"
|
||||
);
|
||||
for _ in 0..2 {
|
||||
assert_eq!(
|
||||
handle
|
||||
.restore(&WorkerOperationContext::Backend, request.clone())
|
||||
.await
|
||||
.unwrap()
|
||||
.state,
|
||||
server_api::WorkerRestoreState::Accepted
|
||||
);
|
||||
assert!(peer.repository_access_requests.lock().unwrap().is_empty());
|
||||
assert_eq!(execution.restore_operations.lock().unwrap().len(), 1);
|
||||
assert_eq!(
|
||||
api.store
|
||||
.list_worker_workdir_links(api.workspace_id(), &identity)
|
||||
.unwrap(),
|
||||
links
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,6 +256,7 @@ fn read_result() -> WorkdirSessionOperationResult {
|
||||
total_lines: 1,
|
||||
content_hash: sha2::Sha256::digest(b"visible\n").into(),
|
||||
truncated: false,
|
||||
byte_truncated: false,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
//! Subject resource checks at the Workdir provider boundary, in addition to existing ceilings.
|
||||
//!
|
||||
//! Paths here are attachment-relative lexical resources. Providers still own resolved-path,
|
||||
//! symlink, capability and checkout validation; this helper never substitutes for those checks.
|
||||
|
||||
use server_api::authorization::{PermissionResource, ScopedPermission};
|
||||
use workdir::{
|
||||
WorkdirPath, WorkdirSessionCapabilities, WorkdirToolScopePermission,
|
||||
http::WorkdirSessionOperation,
|
||||
};
|
||||
|
||||
use super::{ApiError, ApiResult, Error, RuntimeWorkerRef, WorkspaceApi};
|
||||
use crate::store::WorkerWorkdirLinkRecord;
|
||||
|
||||
const READ: &str = "filesystem.read";
|
||||
const WRITE: &str = "filesystem.write";
|
||||
const COMMAND: &str = "bash.execute";
|
||||
|
||||
pub(super) fn authorize(
|
||||
api: &WorkspaceApi,
|
||||
worker: &RuntimeWorkerRef,
|
||||
link: &WorkerWorkdirLinkRecord,
|
||||
operation: &WorkdirSessionOperation,
|
||||
) -> ApiResult<()> {
|
||||
// These are reconciliation/termination of an already registered effect, not execution.
|
||||
// Keep the existing worker/attachment/opaque-handle ownership checks on these routes.
|
||||
if is_command_reconciliation(operation) {
|
||||
return Ok(());
|
||||
}
|
||||
let permissions = super::subject_authorization::worker_permissions(api, worker)?;
|
||||
authorize_permissions(permissions.as_deref(), &link.workdir_id, operation)
|
||||
}
|
||||
|
||||
fn is_command_reconciliation(operation: &WorkdirSessionOperation) -> bool {
|
||||
matches!(
|
||||
operation,
|
||||
WorkdirSessionOperation::CommandCancel(_) | WorkdirSessionOperation::CommandStatus(_)
|
||||
)
|
||||
}
|
||||
|
||||
fn denied(message: &str) -> ApiError {
|
||||
Error::WorkspacePermissionDenied(format!("Workdir Subject authorization: {message}")).into()
|
||||
}
|
||||
|
||||
fn resource(
|
||||
working_directory_id: &str,
|
||||
operation: &str,
|
||||
path: &WorkdirPath,
|
||||
recursive: bool,
|
||||
) -> ApiResult<ScopedPermission> {
|
||||
// FsPath also accepts explicitly scoped absolute paths. Subject resources here are
|
||||
// attachment-relative only, even if the provider separately supports absolute grants.
|
||||
let normalized = WorkdirPath::new(path.as_str())
|
||||
.map_err(|_| denied("operation path must be attachment-relative without escape"))?;
|
||||
let permission = ScopedPermission {
|
||||
operation: operation.into(),
|
||||
resource: PermissionResource::Workdir {
|
||||
working_directory_id: working_directory_id.into(),
|
||||
path: if normalized.is_root() {
|
||||
".".into()
|
||||
} else {
|
||||
normalized.as_str().into()
|
||||
},
|
||||
recursive,
|
||||
},
|
||||
};
|
||||
permission
|
||||
.validate()
|
||||
.map_err(|_| denied("invalid normalized operation resource"))?;
|
||||
Ok(permission)
|
||||
}
|
||||
|
||||
fn require(
|
||||
permissions: &[ScopedPermission],
|
||||
working_directory_id: &str,
|
||||
operation: &str,
|
||||
path: &WorkdirPath,
|
||||
recursive: bool,
|
||||
) -> ApiResult<()> {
|
||||
let requested = resource(working_directory_id, operation, path, recursive)?;
|
||||
if permissions.iter().any(|granted| granted.covers(&requested)) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(denied("operation or resource is outside the current grant"))
|
||||
}
|
||||
}
|
||||
|
||||
fn authorize_permissions(
|
||||
permissions: Option<&[ScopedPermission]>,
|
||||
working_directory_id: &str,
|
||||
operation: &WorkdirSessionOperation,
|
||||
) -> ApiResult<()> {
|
||||
if is_command_reconciliation(operation) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(permissions) = permissions else {
|
||||
// Non-Subject Workers retain the existing provider/Profile/attachment ceilings.
|
||||
return Ok(());
|
||||
};
|
||||
let check = |operation, path: &WorkdirPath, recursive| {
|
||||
require(
|
||||
permissions,
|
||||
working_directory_id,
|
||||
operation,
|
||||
path,
|
||||
recursive,
|
||||
)
|
||||
};
|
||||
match operation {
|
||||
WorkdirSessionOperation::Stat(request) => check(READ, &request.path, false),
|
||||
WorkdirSessionOperation::Read(request) => check(READ, &request.path, false),
|
||||
WorkdirSessionOperation::ReadBytes(request) => check(READ, &request.path, false),
|
||||
WorkdirSessionOperation::Write(request) => check(WRITE, &request.path, false),
|
||||
WorkdirSessionOperation::Edit(request) => check(WRITE, &request.path, false),
|
||||
WorkdirSessionOperation::List(request) => {
|
||||
// Directory enumeration accesses children, not just the directory inode.
|
||||
check(READ, &request.path, true)?;
|
||||
if let Some(cursor) = &request.after {
|
||||
check(READ, &cursor.path, false)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
WorkdirSessionOperation::Glob(request) => check(READ, &request.path, true),
|
||||
WorkdirSessionOperation::Grep(request) => check(READ, &request.path, true),
|
||||
WorkdirSessionOperation::AuthorizeScope(request) => check(
|
||||
match request.permission {
|
||||
WorkdirToolScopePermission::Read => READ,
|
||||
WorkdirToolScopePermission::Write => WRITE,
|
||||
},
|
||||
&request.path,
|
||||
false,
|
||||
),
|
||||
WorkdirSessionOperation::CommandStart(request) => {
|
||||
// Validating cwd is not a shell confinement mechanism. The command can cd elsewhere,
|
||||
// so a prefix grant (even one covering cwd recursively) is never enough.
|
||||
resource(working_directory_id, COMMAND, &request.cwd, false)?;
|
||||
check(COMMAND, &WorkdirPath::root(), true)
|
||||
}
|
||||
WorkdirSessionOperation::CommandOutput(_) => {
|
||||
// Output may contain anything the original shell read. Unlike status/cancel,
|
||||
// retrieving its contents requires a current whole-attachment command grant.
|
||||
check(COMMAND, &WorkdirPath::root(), true)
|
||||
}
|
||||
WorkdirSessionOperation::CommandCancel(_) | WorkdirSessionOperation::CommandStatus(_) => {
|
||||
Ok(())
|
||||
}
|
||||
// These contracts have additional targets, scopes or provider-resolved metadata.
|
||||
// Deny explicitly until all their accesses can be mapped to scoped resources.
|
||||
WorkdirSessionOperation::ScopeRulesOverlap(_)
|
||||
| WorkdirSessionOperation::CheckoutObserve(_)
|
||||
| WorkdirSessionOperation::CheckoutExecute(_)
|
||||
| WorkdirSessionOperation::CheckoutSearch(_) => {
|
||||
Err(denied("operation has no scoped Subject resource mapping"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Attenuate coarse attachment capabilities, without converting a path grant into root scope.
|
||||
/// Per-operation authorization above remains required, including for recursive searches.
|
||||
pub(super) fn intersect_capabilities(
|
||||
permissions: &[ScopedPermission],
|
||||
working_directory_id: &str,
|
||||
ceiling: WorkdirSessionCapabilities,
|
||||
) -> WorkdirSessionCapabilities {
|
||||
let has_path_grant = |operation: &str| {
|
||||
permissions.iter().any(|grant| {
|
||||
grant.operation == operation
|
||||
&& grant.validate().is_ok()
|
||||
&& matches!(&grant.resource, PermissionResource::Workdir {
|
||||
working_directory_id: id, ..
|
||||
} if id == working_directory_id)
|
||||
})
|
||||
};
|
||||
let mut allowed = WorkdirSessionCapabilities::EMPTY;
|
||||
if has_path_grant(READ) {
|
||||
allowed = allowed.union(WorkdirSessionCapabilities::READ_ONLY);
|
||||
}
|
||||
if has_path_grant(WRITE) {
|
||||
allowed = allowed.union(WorkdirSessionCapabilities::WRITE_ONLY);
|
||||
}
|
||||
if require(
|
||||
permissions,
|
||||
working_directory_id,
|
||||
COMMAND,
|
||||
&WorkdirPath::root(),
|
||||
true,
|
||||
)
|
||||
.is_ok()
|
||||
{
|
||||
allowed = allowed.union(WorkdirSessionCapabilities::COMMAND_ONLY);
|
||||
}
|
||||
ceiling.intersection(allowed)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use workdir::{
|
||||
CommandHandle, CommandOutputRequest, CommandRequest, EditRequest, GlobRequest, GrepRequest,
|
||||
ListRequest, ReadBytesRequest, ReadRequest, StatRequest, WriteRequest,
|
||||
};
|
||||
|
||||
fn path(value: &str) -> WorkdirPath {
|
||||
WorkdirPath::new(value).unwrap()
|
||||
}
|
||||
|
||||
fn grant(operation: &str, target: &str, recursive: bool) -> ScopedPermission {
|
||||
resource("wd", operation, &path(target), recursive).unwrap()
|
||||
}
|
||||
|
||||
fn allowed(grants: &[ScopedPermission], operation: &WorkdirSessionOperation) -> bool {
|
||||
authorize_permissions(Some(grants), "wd", operation).is_ok()
|
||||
}
|
||||
|
||||
fn read(target: &str) -> WorkdirSessionOperation {
|
||||
WorkdirSessionOperation::Read(ReadRequest {
|
||||
path: path(target),
|
||||
offset: 0,
|
||||
limit: 10,
|
||||
max_bytes: 1024,
|
||||
})
|
||||
}
|
||||
|
||||
fn command(cwd: &str) -> WorkdirSessionOperation {
|
||||
WorkdirSessionOperation::CommandStart(CommandRequest {
|
||||
command: "cd ..; cat secret".into(),
|
||||
timeout_secs: 1,
|
||||
transport_byte_budget: 1024,
|
||||
cwd: path(cwd),
|
||||
tool_call_id: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_filesystem_operations_require_exact_operation_and_attachment_scope() {
|
||||
let read_grant = grant(READ, "src", true);
|
||||
let write_grant = grant(WRITE, "src", true);
|
||||
let reads = [
|
||||
read("src/a"),
|
||||
WorkdirSessionOperation::Stat(StatRequest {
|
||||
path: path("src/a"),
|
||||
}),
|
||||
WorkdirSessionOperation::ReadBytes(ReadBytesRequest {
|
||||
path: path("src/a"),
|
||||
offset: 0,
|
||||
max_bytes: 10,
|
||||
expected_hash: None,
|
||||
}),
|
||||
WorkdirSessionOperation::List(ListRequest {
|
||||
path: path("src"),
|
||||
limit: 10,
|
||||
after: None,
|
||||
}),
|
||||
WorkdirSessionOperation::Glob(GlobRequest {
|
||||
pattern: "**/*".into(),
|
||||
path: path("src"),
|
||||
limit: 10,
|
||||
}),
|
||||
WorkdirSessionOperation::Grep(GrepRequest {
|
||||
pattern: "needle".into(),
|
||||
path: path("src"),
|
||||
glob: None,
|
||||
file_type: None,
|
||||
case_insensitive: false,
|
||||
before_context: 0,
|
||||
after_context: 0,
|
||||
multiline: false,
|
||||
output_mode: Default::default(),
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
}),
|
||||
];
|
||||
for operation in reads {
|
||||
assert!(
|
||||
allowed(std::slice::from_ref(&read_grant), &operation),
|
||||
"{operation:?}"
|
||||
);
|
||||
assert!(
|
||||
!allowed(std::slice::from_ref(&write_grant), &operation),
|
||||
"{operation:?}"
|
||||
);
|
||||
assert!(
|
||||
authorize_permissions(
|
||||
Some(std::slice::from_ref(&read_grant)),
|
||||
"other-wd",
|
||||
&operation
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
let writes = [
|
||||
WorkdirSessionOperation::Write(WriteRequest {
|
||||
path: path("src/a"),
|
||||
content: vec![],
|
||||
expected_hash: None,
|
||||
}),
|
||||
WorkdirSessionOperation::Edit(EditRequest {
|
||||
path: path("src/a"),
|
||||
old_string: "a".into(),
|
||||
new_string: "b".into(),
|
||||
replace_all: false,
|
||||
expected_hash: [0; 32],
|
||||
}),
|
||||
];
|
||||
for operation in writes {
|
||||
assert!(allowed(std::slice::from_ref(&write_grant), &operation));
|
||||
assert!(!allowed(std::slice::from_ref(&read_grant), &operation));
|
||||
}
|
||||
assert!(!allowed(&[read_grant], &read("src-other/a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_workspace_and_parent_operation_names_do_not_grant_file_access() {
|
||||
for permission in [
|
||||
ScopedPermission {
|
||||
operation: "filesystem".into(),
|
||||
resource: PermissionResource::Feature,
|
||||
},
|
||||
grant("filesystem", ".", true),
|
||||
grant("filesystem.read.extra", ".", true),
|
||||
ScopedPermission {
|
||||
operation: READ.into(),
|
||||
resource: PermissionResource::Workspace {
|
||||
workspace_id: "ws".into(),
|
||||
},
|
||||
},
|
||||
] {
|
||||
assert!(!allowed(&[permission], &read("src/a")));
|
||||
}
|
||||
assert!(!allowed(&[], &read("src/a")));
|
||||
assert!(authorize_permissions(None, "wd", &read("src/a")).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonrecursive_grant_cannot_enumerate_descendants_or_search() {
|
||||
let grants = [grant(READ, "src", false)];
|
||||
assert!(allowed(&grants, &read("src")));
|
||||
assert!(!allowed(&grants, &read("src/a")));
|
||||
assert!(!allowed(
|
||||
&grants,
|
||||
&WorkdirSessionOperation::List(ListRequest {
|
||||
path: path("src"),
|
||||
limit: 10,
|
||||
after: None
|
||||
})
|
||||
));
|
||||
assert!(!allowed(
|
||||
&grants,
|
||||
&WorkdirSessionOperation::Glob(GlobRequest {
|
||||
pattern: "a".into(),
|
||||
path: path("src"),
|
||||
limit: 10
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wire_paths_are_normalized_and_escape_is_denied_before_provider_dispatch() {
|
||||
let grants = [grant(READ, "src", true)];
|
||||
for bad in [
|
||||
"/src/a",
|
||||
"../src/a",
|
||||
"src/../secret",
|
||||
"src\\a",
|
||||
"C:/src/a",
|
||||
"src/\u{0}a",
|
||||
] {
|
||||
let operation = serde_json::from_value::<WorkdirSessionOperation>(json!({
|
||||
"operation": "stat", "request": {"path": bad}
|
||||
}));
|
||||
// Some escapes are already rejected by FsPath's wire boundary. Absolute
|
||||
// provider paths and malformed resource names must also fail this boundary.
|
||||
if let Ok(operation) = operation {
|
||||
assert!(!allowed(&grants, &operation), "{bad:?}");
|
||||
}
|
||||
}
|
||||
let operation: WorkdirSessionOperation = serde_json::from_value(json!({
|
||||
"operation": "stat", "request": {"path": "./src//a"}
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(allowed(&grants, &operation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_probes_and_list_cursors_cannot_access_outside_the_granted_resource() {
|
||||
let grants = [grant(READ, "src", true)];
|
||||
for (target, permission, expected) in [
|
||||
("src/a", WorkdirToolScopePermission::Read, true),
|
||||
("private/a", WorkdirToolScopePermission::Read, false),
|
||||
("src/a", WorkdirToolScopePermission::Write, false),
|
||||
] {
|
||||
let operation = WorkdirSessionOperation::AuthorizeScope(
|
||||
workdir::WorkdirScopeAuthorizationRequest {
|
||||
rules: vec![],
|
||||
path: path(target),
|
||||
permission,
|
||||
},
|
||||
);
|
||||
assert_eq!(allowed(&grants, &operation), expected);
|
||||
}
|
||||
let list = |cursor| {
|
||||
WorkdirSessionOperation::List(ListRequest {
|
||||
path: path("src"),
|
||||
limit: 10,
|
||||
after: Some(workdir::ListCursor {
|
||||
kind: workdir::EntryKind::File,
|
||||
path: path(cursor),
|
||||
}),
|
||||
})
|
||||
};
|
||||
assert!(allowed(&grants, &list("src/a")));
|
||||
assert!(!allowed(&grants, &list("private/a")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn arbitrary_shell_requires_recursive_attachment_root_not_cwd_scope() {
|
||||
assert!(!allowed(&[grant(COMMAND, "src", true)], &command("src")));
|
||||
assert!(!allowed(&[grant(COMMAND, ".", false)], &command("src")));
|
||||
assert!(!allowed(&[grant(WRITE, ".", true)], &command("src")));
|
||||
assert!(allowed(&[grant(COMMAND, ".", true)], &command("src")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revocation_denies_new_execute_and_output_but_preserves_cancel_and_status() {
|
||||
let handle = CommandHandle("owned-opaque-handle".into());
|
||||
assert!(allowed(
|
||||
&[],
|
||||
&WorkdirSessionOperation::CommandCancel(handle.clone())
|
||||
));
|
||||
assert!(allowed(
|
||||
&[],
|
||||
&WorkdirSessionOperation::CommandStatus(handle.clone())
|
||||
));
|
||||
assert!(!allowed(&[], &command(".")));
|
||||
let output = WorkdirSessionOperation::CommandOutput(CommandOutputRequest {
|
||||
handle,
|
||||
cursor: 0,
|
||||
limit: 10,
|
||||
wait: false,
|
||||
});
|
||||
assert!(!allowed(&[], &output));
|
||||
assert!(allowed(&[grant(COMMAND, ".", true)], &output));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_checkout_and_scope_comparison_fail_closed_for_scoped_workers() {
|
||||
let grants = [
|
||||
grant(READ, ".", true),
|
||||
grant(WRITE, ".", true),
|
||||
grant(COMMAND, ".", true),
|
||||
];
|
||||
let operations = [
|
||||
serde_json::from_value::<WorkdirSessionOperation>(json!({
|
||||
"operation": "scope_rules_overlap",
|
||||
"request": {
|
||||
"left": {"target": "src", "permission": "read", "recursive": true},
|
||||
"right": {"target": "private", "permission": "read", "recursive": true}
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
WorkdirSessionOperation::CheckoutObserve(path("src")),
|
||||
WorkdirSessionOperation::CheckoutExecute(workdir::CheckoutRequest {
|
||||
target: path("src"),
|
||||
validator: vec![],
|
||||
operation: workdir::CheckoutOperation::Create {
|
||||
path: path("outside/a"),
|
||||
content: vec![],
|
||||
},
|
||||
}),
|
||||
WorkdirSessionOperation::CheckoutSearch(workdir::CheckoutSearchRequest::new(
|
||||
workdir::CheckoutSearchOperation::List(ListRequest {
|
||||
path: path("src"),
|
||||
limit: 10,
|
||||
after: None,
|
||||
}),
|
||||
)),
|
||||
];
|
||||
for operation in operations {
|
||||
assert!(!allowed(&grants, &operation));
|
||||
assert!(authorize_permissions(None, "wd", &operation).is_ok());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readonly_delegation_attenuates_edit_capable_profile_without_losing_narrow_read_scope() {
|
||||
let grants = [grant(READ, "src", true)];
|
||||
assert_eq!(
|
||||
intersect_capabilities(&grants, "wd", WorkdirSessionCapabilities::ALL),
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
);
|
||||
assert_eq!(
|
||||
intersect_capabilities(&grants, "other", WorkdirSessionCapabilities::ALL),
|
||||
WorkdirSessionCapabilities::EMPTY
|
||||
);
|
||||
assert_eq!(
|
||||
intersect_capabilities(
|
||||
&[grant(WRITE, ".", true)],
|
||||
"wd",
|
||||
WorkdirSessionCapabilities::READ_ONLY
|
||||
),
|
||||
WorkdirSessionCapabilities::EMPTY
|
||||
);
|
||||
assert_eq!(
|
||||
intersect_capabilities(
|
||||
&[grant(COMMAND, "src", true)],
|
||||
"wd",
|
||||
WorkdirSessionCapabilities::ALL
|
||||
),
|
||||
WorkdirSessionCapabilities::EMPTY
|
||||
);
|
||||
assert_eq!(
|
||||
intersect_capabilities(
|
||||
&[grant(COMMAND, ".", true)],
|
||||
"wd",
|
||||
WorkdirSessionCapabilities::READ_WRITE
|
||||
),
|
||||
WorkdirSessionCapabilities::EMPTY
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -466,7 +466,6 @@ impl AttentionWorkerFactory {
|
||||
worker.with_notification_coalesce_delay(std::time::Duration::from_millis(10)),
|
||||
worker::WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir: root.join(uuid::Uuid::now_v7().to_string()),
|
||||
bash_output_dir: root.join("bash-output"),
|
||||
},
|
||||
worker::WorkerControllerTransport::InProcess,
|
||||
)
|
||||
|
||||
@@ -799,7 +799,6 @@ impl DeliveryWorkerFactory {
|
||||
worker::WorkerFilesystemAuthority::None,
|
||||
worker::WorkerBootstrapLayout::RuntimeManagedRun {
|
||||
run_dir: root.join(uuid::Uuid::now_v7().to_string()),
|
||||
bash_output_dir: root.join("bash-output"),
|
||||
},
|
||||
worker::WorkerControllerTransport::InProcess,
|
||||
)
|
||||
|
||||
@@ -53,6 +53,7 @@ async fn start_source(api: &mut WorkspaceApi, runtime: &WorkdirlessFixtureRuntim
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
},
|
||||
);
|
||||
summary.tags = vec!["orchestrator".into(), "singleton".into()];
|
||||
|
||||
@@ -59,6 +59,7 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
|
||||
workdir_attachments: Vec::new(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
@@ -794,6 +795,7 @@ async fn tracking_fixture(
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
},
|
||||
);
|
||||
summary.tags = vec!["orchestrator".into(), "singleton".into()];
|
||||
|
||||
@@ -238,6 +238,25 @@ pub(super) async fn root(
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
blocking(false, move || drive.root()).await.map(public_node)
|
||||
}
|
||||
/// Query only the authenticated caller's current ceiling using the operation authorizer.
|
||||
/// This is admission/publication evidence, never authority retained for a later mutation.
|
||||
pub(super) async fn capabilities(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
workspace: &str,
|
||||
) -> DriveResult<DriveCapabilities> {
|
||||
let drive = drive_grants::authorized_drive(api, context, workspace, false).await?;
|
||||
let write = blocking(false, move || match drive.check_authorized(true) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(workspace_drive::Error::Denied) => {
|
||||
drive.check_authorized(false)?;
|
||||
Ok(false)
|
||||
}
|
||||
Err(error) => Err(error),
|
||||
})
|
||||
.await?;
|
||||
Ok(DriveCapabilities { write })
|
||||
}
|
||||
pub(super) async fn metadata(
|
||||
api: &WorkspaceApi,
|
||||
context: &ServerRequestContext,
|
||||
@@ -581,6 +600,9 @@ pub(super) fn router(service: Arc<ServerApiContractService>) -> Router {
|
||||
.merge(streaming_router(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_root(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_metadata(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_capabilities(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::drive_list(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_search(service.clone()))
|
||||
.merge(server_api::server_api_axum::drive_read_text(
|
||||
|
||||
@@ -14,7 +14,7 @@ use worker::{
|
||||
struct TicketRouterClient {
|
||||
api: WorkspaceApi,
|
||||
identity: Arc<RuntimeIdentityMaterial>,
|
||||
source: RuntimeWorkerRef,
|
||||
source: Mutex<RuntimeWorkerRef>,
|
||||
runtime: tokio::runtime::Handle,
|
||||
lose_next_response: AtomicBool,
|
||||
requests: Mutex<Vec<(String, u16, String)>>,
|
||||
@@ -45,9 +45,10 @@ impl WorkspaceClient for TicketRouterClient {
|
||||
WorkspaceRequestMethod::Patch => "PATCH",
|
||||
WorkspaceRequestMethod::Delete => "DELETE",
|
||||
};
|
||||
let source = self.source.lock().unwrap().clone();
|
||||
let signed = runtime_source_request(
|
||||
&self.identity,
|
||||
Some(&self.source.worker_id),
|
||||
Some(&source.worker_id),
|
||||
method,
|
||||
&request.path,
|
||||
request.body.unwrap_or_default().into_bytes(),
|
||||
@@ -225,7 +226,7 @@ fn ticket_client(
|
||||
Arc::new(TicketRouterClient {
|
||||
api: api.clone(),
|
||||
identity: Arc::new(identity),
|
||||
source: source.clone(),
|
||||
source: Mutex::new(source.clone()),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
lose_next_response: AtomicBool::new(false),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
@@ -307,18 +308,16 @@ async fn ticket_public_roundtrip_observed_answer_replays_without_changing_waitin
|
||||
assert!(error.to_string().contains("409"), "{error}");
|
||||
// A valid Runtime signature is not a Worker membership grant. The
|
||||
// Backend must reject this unregistered source through the same tools.
|
||||
let denied = Arc::new(TicketRouterClient {
|
||||
api: api.clone(),
|
||||
identity: client.identity.clone(),
|
||||
source: RuntimeWorkerRef::new(
|
||||
source.runtime_id.clone(),
|
||||
"00000000-0000-7000-8000-000000000099",
|
||||
),
|
||||
runtime: tokio::runtime::Handle::current(),
|
||||
lose_next_response: AtomicBool::new(false),
|
||||
requests: Mutex::new(Vec::new()),
|
||||
});
|
||||
let denied_surface = TicketSurface::new(&dir.path().join("denied"), denied, wip).await;
|
||||
// Activate the real Feature while the source is registered, then lose
|
||||
// membership before dispatch. Otherwise activation itself correctly rejects
|
||||
// the fixture before it can exercise the operation's authentication check.
|
||||
let denied = ticket_client(&api, (*client.identity).clone(), &source);
|
||||
let denied_surface =
|
||||
TicketSurface::new(&dir.path().join("denied"), denied.clone(), wip).await;
|
||||
*denied.source.lock().unwrap() = RuntimeWorkerRef::new(
|
||||
source.runtime_id.clone(),
|
||||
"00000000-0000-7000-8000-000000000099",
|
||||
);
|
||||
let error = denied_surface
|
||||
.call("TicketAssignmentAnswer", input.clone(), context("denied"))
|
||||
.await
|
||||
|
||||
@@ -187,7 +187,7 @@ async fn config_call(
|
||||
runtime
|
||||
.invoke(
|
||||
path.into(),
|
||||
config_interface(path),
|
||||
if matches!(operation,"edit"|"write") {wip_protocol::InterfaceReference {scope:"/".into(),name:format!("yoi.file/{operation}-text/v1")}} else {config_interface(path)},
|
||||
operation.into(),
|
||||
args,
|
||||
agen::tool::ToolExecutionContext::direct(),
|
||||
@@ -859,6 +859,7 @@ async fn workspace_config_actual_remote_and_embedded_clients_use_authenticated_w
|
||||
workdir_attachments: Vec::new(),
|
||||
feature_connections: Default::default(),
|
||||
control_operation_id: None,
|
||||
execution_grant: None,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -7131,6 +7131,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> {
|
||||
self.with_immediate_transaction(|conn| {
|
||||
activation::require_active(conn, &record.user_id)?;
|
||||
// Retain the real row throughout the non-authenticating recovery window, not
|
||||
// forever. Issuance amortizes reclamation without extending marker lifetime or
|
||||
// introducing another store/scheduler. Inactive stores do not grow between issues.
|
||||
let cutoff = (chrono::Utc::now()
|
||||
- chrono::Duration::seconds(crate::auth::SESSION_EXPIRY_GRACE_SECONDS))
|
||||
.to_rfc3339();
|
||||
conn.execute(
|
||||
"DELETE FROM browser_sessions WHERE julianday(expires_at) <= julianday(?1) OR julianday(expires_at) IS NULL",
|
||||
params![cutoff],
|
||||
)?;
|
||||
conn.execute(
|
||||
r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
|
||||
@@ -21014,6 +21024,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
resolved_worker_observation_enabled: false,
|
||||
resolved_worker_observation_grants: Vec::new(),
|
||||
resolved_control_operation: None,
|
||||
resolved_execution_grant: None,
|
||||
resolved_workspace_api: None,
|
||||
resolved_memory_settings: None,
|
||||
resolved_subjektiv_attached: false,
|
||||
|
||||
Reference in New Issue
Block a user