fix: reconcile Ticket tags with current develop contracts and Runtime auth

This commit is contained in:
2026-10-11 02:03:44 +09:00
67 changed files with 5629 additions and 4519 deletions
+1 -15
View File
@@ -1139,10 +1139,6 @@ pub struct RemainingRuntimeRoute {
pub reason: &'static str,
}
pub const RUNTIME_ROUTE_VERIFICATION_CHALLENGE: &str =
"/v1/workspace-runtime-verification/challenge";
pub const RUNTIME_ROUTE_VERIFICATION_ACK: &str =
"/v1/workspace-runtime-verification/acknowledgement";
pub const RUNTIME_ROUTE_CONFIG_BUNDLES: &str = "/v1/config-bundles";
pub const RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY: &str =
"/v1/config-bundles/{bundle_id}/availability";
@@ -1169,16 +1165,6 @@ pub const RUNTIME_ROUTE_WORKER_SESSION_ATTACHMENT: &str =
/// Intentionally out-of-contract Runtime routes. This inventory keeps manual paths visible rather
/// than letting them be mistaken for management-contract omissions.
pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
RemainingRuntimeRoute {
method: "POST",
path: RUNTIME_ROUTE_VERIFICATION_CHALLENGE,
reason: "Workspace verification handshake",
},
RemainingRuntimeRoute {
method: "POST",
path: RUNTIME_ROUTE_VERIFICATION_ACK,
reason: "Workspace verification handshake",
},
RemainingRuntimeRoute {
method: "GET",
path: RUNTIME_ROUTE_CONFIG_BUNDLES,
@@ -1625,7 +1611,7 @@ mod tests {
#[test]
fn remaining_route_inventory_does_not_overlap_contract() {
assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 21);
assert_eq!(REMAINING_RUNTIME_ROUTES.len(), 19);
for remaining in REMAINING_RUNTIME_ROUTES {
assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| {
format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method)
+33 -82
View File
@@ -8891,62 +8891,14 @@ pub struct RuntimeSummary {
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceRuntimeBindingState {
Configured,
Verified,
Revoked,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeConnectionDisplayState {
Configured,
Verified,
Unavailable,
Revoked,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeVerificationOutcome {
Verified,
ChallengeIssued,
VerificationFailed,
ConnectivityFailed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeVerificationEvidenceSummary {
pub verified_at: Option<String>,
pub last_checked_at: String,
pub last_outcome: RuntimeVerificationOutcome,
pub binding_id: String,
pub workspace_key_id: String,
pub workspace_public_key_fingerprint: String,
pub workspace_trust_id: String,
pub runtime_public_key_fingerprint: String,
}
/// Binding identity and explicit revocation only. Each signed request authenticates
/// using the current Workspace signing identity, not a stored approval.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRuntimeBindingSummary {
pub state: WorkspaceRuntimeBindingState,
pub connection_state: RuntimeConnectionDisplayState,
pub binding_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_trust_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification: Option<RuntimeVerificationEvidenceSummary>,
pub revoked_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -9130,9 +9082,6 @@ pub struct RuntimePublicIdentityBundle {
#[serde(deny_unknown_fields)]
pub struct CreateRemoteRuntimeRequest {
pub public_bundle: RuntimePublicIdentityBundle,
/// Exact Runtime-issued enrollment identity from `trust-workspace add/show`.
/// A key fingerprint alone cannot distinguish revocation and re-enrollment.
pub workspace_trust_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub endpoint: String,
@@ -9175,12 +9124,11 @@ pub enum RuntimeConnectionTestFailureKind {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
/// Observation of an actual signed ping, not a grant or cached approval.
pub struct RuntimeConnectionTestResponse {
pub workspace_id: String,
pub runtime_id: String,
pub binding_id: String,
pub connection_state: RuntimeConnectionDisplayState,
pub verification: Option<RuntimeVerificationEvidenceSummary>,
pub checked_at: String,
pub status: RuntimeConnectionTestStatus,
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
@@ -12829,18 +12777,22 @@ mod tests {
}
#[test]
fn runtime_registration_requires_explicit_runtime_issued_trust_identity() {
let mut value = serde_json::json!({
fn runtime_registration_uses_runtime_bundle_without_workspace_identity_copies() {
let value = serde_json::json!({
"public_bundle": {"identity_id": "runtime", "public_key": "public-key"},
"endpoint": "https://runtime.example.test",
"workspace_trust_id": "runtime-issued-enrollment"
"endpoint": "https://runtime.example.test"
});
let request: CreateRemoteRuntimeRequest = serde_json::from_value(value.clone()).unwrap();
assert_eq!(request.workspace_trust_id, "runtime-issued-enrollment");
value.as_object_mut().unwrap().remove("workspace_trust_id");
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(value.clone()).is_err());
value["workspace_key_generation"] = serde_json::json!(1);
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(value).is_err());
assert_eq!(serde_json::to_value(request).unwrap(), value);
for field in [
"workspace_trust_id",
"workspace_key_id",
"workspace_key_generation",
] {
let mut obsolete = value.clone();
obsolete[field] = serde_json::json!("obsolete");
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(obsolete).is_err());
}
}
#[test]
@@ -12849,8 +12801,6 @@ mod tests {
"workspace_id": "workspace-test",
"runtime_id": "runtime-test",
"binding_id": "binding-test",
"connection_state": "verified",
"verification": null,
"checked_at": "2026-09-01T12:00:00Z",
"status": "compatible",
"failure_kind": null,
@@ -12868,21 +12818,22 @@ mod tests {
}
#[test]
fn runtime_trust_contract_binds_concrete_keys_and_binding_lifetimes() {
let evidence = RuntimeVerificationEvidenceSummary {
verified_at: Some("2026-10-06T00:00:00Z".into()),
last_checked_at: "2026-10-06T00:00:00Z".into(),
last_outcome: RuntimeVerificationOutcome::Verified,
binding_id: "binding-new".into(),
workspace_key_id: "workspace-key".into(),
workspace_public_key_fingerprint: "sha256:workspace-key".into(),
workspace_trust_id: "trust-new".into(),
runtime_public_key_fingerprint: "sha256:runtime-key".into(),
};
round_trip(evidence.clone());
let mut stale = serde_json::to_value(evidence).unwrap();
stale["unexpected_state_guard"] = serde_json::json!(1);
assert!(serde_json::from_value::<RuntimeVerificationEvidenceSummary>(stale).is_err());
fn runtime_binding_rejects_cached_authentication_and_requires_observed_identity_for_mutation() {
let binding = serde_json::json!({"binding_id": "binding-new", "revoked_at": null});
let parsed: WorkspaceRuntimeBindingSummary =
serde_json::from_value(binding.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), binding);
for field in [
"state",
"connection_state",
"workspace_key_id",
"workspace_trust_id",
"verification",
] {
let mut obsolete = binding.clone();
obsolete[field] = serde_json::json!("obsolete");
assert!(serde_json::from_value::<WorkspaceRuntimeBindingSummary>(obsolete).is_err());
}
round_trip(RevokeRuntimeTrustKeyRequest {
expected_binding_id: "binding-observed".into(),
});
+2
View File
@@ -67,6 +67,8 @@ pub enum LoggedSessionHistoryOrigin {
ToolOutput {
worker: LoggedWorkerSubject,
},
/// Host-produced opening context summary; not auto-read or TaskStore material.
CompactionSummary,
DerivedSummary,
LegacyUnknown,
}
+8 -1
View File
@@ -1877,7 +1877,8 @@ fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
}
LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
LoggedSessionHistoryOrigin::CompactionSummary
| LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
LoggedSessionHistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
}
}
@@ -2548,6 +2549,12 @@ mod tests {
user_message(10),
assistant_message(10, "final"),
synthetic_compaction_input,
history_message(
"host-summary",
LoggedRole::System,
"[Compacted context summary]\n\nprivate inherited context",
LoggedSessionHistoryOrigin::CompactionSummary,
),
],
None,
Some(SegmentOrigin {
+15
View File
@@ -27,4 +27,19 @@ cargo run -p worker-runtime \
```
The REST server is intended for a trusted Backend/proxy, not direct browser access.
Workspace requests authenticate the actual HTTP request with the Workspace's current
signing identity. The Runtime verifies signatures against its explicitly enrolled
Workspace public key and binds the Runtime audience, Workspace/Worker scope,
operation, method, path/query, body digest, expiry, and single-use token ID. A
connection test is an ordinary signed `/v1/ping`; no prior handshake or saved
verification evidence is required or consulted.
Keep `trust-workspace add`, `replace`, and `revoke` as explicit operator actions.
A self-reported public key cannot grant trust. Trust record IDs are administrative
only and must not be copied into Backend registration or request claims. Replay
protection uses Workspace plus the trusted key fingerprint, so re-enrolling the
same key or rotating away and back cannot reset consumed requests. Reaccepting a
key does permit fresh, unconsumed requests signed by that key until their short
expiry; it does not create a separate sender-controlled enrollment namespace.
For authenticated remote Runtime setup, see [`../../docs/development/server-runtime-auth.md`](../../docs/development/server-runtime-auth.md).
+272 -627
View File
@@ -31,11 +31,7 @@ use crate::ssh_host_key_probe::{
SshHostKeyProbeRequest, SshHostKeyProbeResponse, probe_ssh_host_keys_with_program,
};
use crate::workspace_issuer::{
RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_OPERATION,
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerifier,
WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationAuthority,
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
WorkspaceRuntimeVerificationRecord, WorkspaceRuntimeVerificationResponse,
inspect_workspace_capability_claims, workspace_request_body_digest,
};
use crate::{Runtime, RuntimeWorkspaceScope};
@@ -256,14 +252,6 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
};
let router = Router::new()
.route(
remaining_route(runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE, &["POST"]),
post(post_workspace_verification_challenge),
)
.route(
remaining_route(runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK, &["POST"]),
post(post_workspace_verification_acknowledgement),
)
.route(
remaining_route(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES, &["GET", "POST"]),
get(list_config_bundles).post(store_config_bundle),
@@ -377,8 +365,7 @@ struct RuntimeHttpState {
#[derive(Clone, Debug)]
pub struct WorkspaceRuntimeHttpAuth {
pub verifier: WorkspaceCapabilityVerifier,
pub signer: RuntimeVerificationSigner,
pub verifications: Arc<dyn WorkspaceRuntimeVerificationAuthority>,
pub runtime_id: String,
}
struct RuntimeHttpWorkdirSession {
@@ -563,172 +550,6 @@ fn unix_now_i64() -> i64 {
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX)
}
async fn post_workspace_verification_challenge(
State(state): State<RuntimeHttpState>,
Extension(verified): Extension<VerifiedWorkspaceCapability>,
Json(challenge): Json<WorkspaceRuntimeVerificationChallenge>,
) -> RestResult<WorkspaceRuntimeVerificationResponse> {
let auth = state.workspace_auth.as_deref().ok_or_else(|| {
RuntimeHttpRestError::new(
StatusCode::NOT_IMPLEMENTED,
"workspace_runtime_verification_unavailable",
"Workspace Runtime verification is not configured",
)
})?;
if challenge.workspace_id != verified.workspace_id
|| challenge.runtime_id != verified.runtime_id
|| challenge.binding_id != verified.binding_id
|| challenge.workspace_key_id != verified.issuer_key_id
|| challenge.workspace_public_key_fingerprint != verified.issuer_public_key_fingerprint
|| challenge.workspace_trust_id != verified.trust_id
|| challenge.runtime_id != auth.signer.runtime_id()
|| challenge.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
{
return Err(RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"workspace_runtime_verification_rejected",
"Workspace Runtime verification challenge does not match authenticated authority",
));
}
let response = auth
.signer
.sign_response(&challenge, uuid::Uuid::now_v7().to_string(), unix_now_i64())
.map_err(|error| {
RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"workspace_runtime_verification_rejected",
error.to_string(),
)
})?;
auth.verifications.begin(challenge).map_err(|error| {
RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE,
"workspace_runtime_verification_unavailable",
error.to_string(),
)
})?;
Ok(Json(response))
}
async fn post_workspace_verification_acknowledgement(
State(state): State<RuntimeHttpState>,
Extension(verified): Extension<VerifiedWorkspaceCapability>,
Json(acknowledgement): Json<WorkspaceRuntimeVerificationAcknowledgement>,
) -> RestResult<WorkspaceRuntimeVerificationReceipt> {
let auth = state.workspace_auth.as_deref().ok_or_else(|| {
RuntimeHttpRestError::new(
StatusCode::NOT_IMPLEMENTED,
"workspace_runtime_verification_unavailable",
"Workspace Runtime verification is not configured",
)
})?;
if acknowledgement.workspace_id != verified.workspace_id
|| acknowledgement.runtime_id != verified.runtime_id
|| acknowledgement.binding_id != verified.binding_id
|| acknowledgement.workspace_key_id != verified.issuer_key_id
|| acknowledgement.workspace_public_key_fingerprint
!= verified.issuer_public_key_fingerprint
|| acknowledgement.workspace_trust_id != verified.trust_id
|| acknowledgement.runtime_id != auth.signer.runtime_id()
|| acknowledgement.runtime_public_key_fingerprint != auth.signer.public_key_fingerprint()
|| acknowledgement.expires_at <= unix_now_i64()
|| acknowledgement.binding_id.is_empty()
|| acknowledgement.workspace_public_key_fingerprint.is_empty()
|| acknowledgement.workspace_trust_id.is_empty()
|| acknowledgement.workspace_nonce.is_empty()
|| acknowledgement.runtime_nonce.is_empty()
|| acknowledgement.response_digest.len() != workspace_request_body_digest(&[]).len()
{
return Err(RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"workspace_runtime_verification_rejected",
"Workspace Runtime verification acknowledgement is invalid",
));
}
let challenge = WorkspaceRuntimeVerificationChallenge {
challenge_id: acknowledgement.challenge_id.clone(),
workspace_id: acknowledgement.workspace_id.clone(),
runtime_id: acknowledgement.runtime_id.clone(),
binding_id: acknowledgement.binding_id.clone(),
workspace_key_id: acknowledgement.workspace_key_id.clone(),
workspace_public_key_fingerprint: acknowledgement.workspace_public_key_fingerprint.clone(),
workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
runtime_public_key_fingerprint: acknowledgement.runtime_public_key_fingerprint.clone(),
workspace_nonce: acknowledgement.workspace_nonce.clone(),
expires_at: acknowledgement.expires_at,
};
let response = acknowledgement.response.clone();
if response.runtime_nonce != acknowledgement.runtime_nonce
|| response.workspace_nonce != acknowledgement.workspace_nonce
|| response.response_proof.is_empty()
{
return Err(RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"workspace_runtime_verification_rejected",
"Workspace Runtime verification acknowledgement does not match its response",
));
}
let response_bytes = serde_json::to_vec(&response).map_err(|_| {
RuntimeHttpRestError::new(
StatusCode::BAD_REQUEST,
"workspace_runtime_verification_rejected",
"Workspace Runtime verification response could not be canonicalized",
)
})?;
if workspace_request_body_digest(&response_bytes) != acknowledgement.response_digest {
return Err(RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"workspace_runtime_verification_rejected",
"Workspace Runtime verification acknowledgement has the wrong response digest",
));
}
crate::workspace_issuer::verify_runtime_verification_response(
&response,
&challenge,
auth.signer.public_key(),
unix_now_i64(),
)
.map_err(|error| {
RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"workspace_runtime_verification_rejected",
error.to_string(),
)
})?;
auth.verifications
.acknowledge(
&acknowledgement.challenge_id,
WorkspaceRuntimeVerificationRecord {
workspace_id: acknowledgement.workspace_id.clone(),
runtime_id: acknowledgement.runtime_id.clone(),
binding_id: acknowledgement.binding_id.clone(),
workspace_key_id: acknowledgement.workspace_key_id.clone(),
workspace_public_key_fingerprint: acknowledgement
.workspace_public_key_fingerprint
.clone(),
workspace_trust_id: acknowledgement.workspace_trust_id.clone(),
runtime_public_key_fingerprint: acknowledgement
.runtime_public_key_fingerprint
.clone(),
verified_at: unix_now_i64(),
},
)
.map_err(|error| {
RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE,
"workspace_runtime_verification_unavailable",
error.to_string(),
)
})?;
Ok(Json(WorkspaceRuntimeVerificationReceipt {
challenge_id: acknowledgement.challenge_id,
workspace_id: acknowledgement.workspace_id,
runtime_id: acknowledgement.runtime_id,
binding_id: acknowledgement.binding_id.clone(),
accepted_at: unix_now_i64(),
}))
}
#[allow(dead_code)]
async fn get_runtime_ping(
State(state): State<RuntimeHttpState>,
@@ -757,7 +578,7 @@ async fn get_runtime_ping(
let runtime_id = state
.workspace_auth
.as_ref()
.map(|auth| auth.signer.runtime_id().trim())
.map(|auth| auth.runtime_id.trim())
.filter(|runtime_id| !runtime_id.is_empty())
.ok_or_else(|| {
RuntimeHttpRestError::new(
@@ -2123,8 +1944,7 @@ async fn require_runtime_auth(
let body_digest = workspace_request_body_digest(&body);
let expected = WorkspaceCapabilityExpectation {
workspace_id: &claims.issuer_workspace_id,
binding_id: &claims.binding_id,
runtime_id: workspace_auth.signer.runtime_id(),
runtime_id: &workspace_auth.runtime_id,
worker_id: expected_worker_id.as_deref(),
operation: required_permission,
method: &method,
@@ -2134,48 +1954,6 @@ async fn require_runtime_auth(
};
match workspace_auth.verifier.verify(token, &expected) {
Ok(verified) => {
let is_verification = path_and_query
== runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
|| path_and_query == runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK;
if !is_verification {
let record = match workspace_auth
.verifications
.get(&verified.workspace_id, workspace_auth.signer.runtime_id())
{
Ok(Some(record)) => record,
Ok(None) => {
return RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_runtime_verification_required",
"Workspace Runtime binding has not completed signed verification",
)
.into_response();
}
Err(error) => {
return RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE,
"workspace_runtime_verification_unavailable",
error.to_string(),
)
.into_response();
}
};
if record.binding_id != verified.binding_id
|| record.workspace_key_id != verified.issuer_key_id
|| record.workspace_public_key_fingerprint
!= verified.issuer_public_key_fingerprint
|| record.workspace_trust_id != verified.trust_id
|| record.runtime_public_key_fingerprint
!= workspace_auth.signer.public_key_fingerprint()
{
return RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_runtime_verification_stale",
"Workspace Runtime verification does not match current request authority",
)
.into_response();
}
}
request = Request::from_parts(parts, Body::from(body));
request.extensions_mut().insert(verified.clone());
request.extensions_mut().insert(RuntimeAuthContext {
@@ -2199,13 +1977,7 @@ async fn require_runtime_auth(
}
}
let workspace_bootstrap_request = request.method() == Method::POST
&& matches!(
request.uri().path(),
runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
| runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK
);
if state.workspace_auth.is_some() && !workspace_bootstrap_request {
if state.workspace_auth.is_some() {
let local_token_matches = state
.local_token
.as_deref()
@@ -2275,12 +2047,6 @@ fn auth_workspace_scope(
}
fn workspace_runtime_operation(method: &Method, path: &str) -> &'static str {
if (path == runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE
|| path == runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK)
&& *method == Method::POST
{
return WORKSPACE_VERIFICATION_OPERATION;
}
required_runtime_permission(method, path).unwrap_or("runtime:read")
}
@@ -2661,12 +2427,10 @@ mod tests {
WorkerExecutionResult, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
};
use crate::management::RuntimeOptions;
use crate::retention::{DiagnosticsDisposition, SessionDisposition};
use crate::workspace_issuer::{
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
WorkspaceCapabilityClaims, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
WorkspaceIssuerTrustState, issue_workspace_capability_token,
verify_runtime_verification_response,
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityClaims,
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
issue_workspace_capability_token,
};
use axum::body::to_bytes;
use axum::http::Method;
@@ -2741,8 +2505,6 @@ mod tests {
.map(|route| route.path)
.collect::<std::collections::BTreeSet<_>>();
for path in [
runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE,
runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK,
runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES,
runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY,
runtime_api::RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS,
@@ -2766,402 +2528,286 @@ mod tests {
}
}
#[tokio::test]
async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
runtime
.store_config_bundle(test_bundle(ProfileSelector::Builtin(
"builtin:coder".to_string(),
)))
.unwrap();
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let workspace_public_key =
crate::auth::decode_public_key(&workspace_identity.public_key).unwrap();
let workspace_fingerprint = format!(
"sha256:{}",
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(workspace_public_key))
);
let runtime_public_key =
crate::auth::decode_public_key(&runtime_identity.public_key).unwrap();
let runtime_fingerprint = format!(
"sha256:{}",
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(runtime_public_key))
);
let verifier = WorkspaceCapabilityVerifier::new(
vec![WorkspaceIssuerTrustRecord {
workspace_id: "workspace-a".to_string(),
backend_url: "https://backend.test".to_string(),
key_id: "workspace-key".to_string(),
algorithm: "ed25519".to_string(),
public_key: workspace_identity.public_key.clone(),
public_key_fingerprint: workspace_fingerprint.clone(),
trust_id: "trust-test".into(),
state: WorkspaceIssuerTrustState::Active,
registered_at_unix: 1,
updated_at_unix: 1,
}],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap();
let app = runtime_http_router_with_workspace_auth(
runtime.clone(),
None,
WorkspaceRuntimeHttpAuth {
verifier,
signer: RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap(),
verifications: Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default()),
},
);
let challenge = WorkspaceRuntimeVerificationChallenge {
challenge_id: "challenge-1".to_string(),
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-test".to_string(),
binding_id: "binding-test".into(),
workspace_key_id: "workspace-key".to_string(),
workspace_public_key_fingerprint: workspace_fingerprint.clone(),
workspace_trust_id: "trust-test".into(),
runtime_public_key_fingerprint: runtime_fingerprint,
workspace_nonce: "workspace-nonce".to_string(),
expires_at: unix_now_i64() + 60,
};
let body = serde_json::to_vec(&challenge).unwrap();
let claims = WorkspaceCapabilityClaims {
issuer: "https://backend.test".to_string(),
issuer_workspace_id: "workspace-a".to_string(),
issuer_key_id: "workspace-key".to_string(),
issuer_public_key_fingerprint: workspace_fingerprint.clone(),
trust_id: "trust-test".into(),
binding_id: "binding-test".into(),
runtime_id: "runtime-test".to_string(),
worker_id: None,
operation: WORKSPACE_VERIFICATION_OPERATION.to_string(),
method: "POST".to_string(),
path_and_query: runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE.to_string(),
body_digest: workspace_request_body_digest(&body),
iat: unix_now_i64(),
exp: challenge.expires_at,
jti: "challenge-token".to_string(),
};
let token =
issue_workspace_capability_token(&workspace_identity.signing_key().unwrap(), &claims)
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(runtime_api::RUNTIME_ROUTE_VERIFICATION_CHALLENGE)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
StatusCode::OK,
"{}",
String::from_utf8_lossy(&response_body)
);
let verification_response =
serde_json::from_slice::<WorkspaceRuntimeVerificationResponse>(&response_body).unwrap();
verify_runtime_verification_response(
&verification_response,
&challenge,
&runtime_identity.public_key,
unix_now_i64(),
)
.unwrap();
let acknowledgement = WorkspaceRuntimeVerificationAcknowledgement {
challenge_id: verification_response.challenge_id.clone(),
workspace_id: verification_response.workspace_id.clone(),
runtime_id: verification_response.runtime_id.clone(),
binding_id: verification_response.binding_id.clone(),
workspace_key_id: verification_response.workspace_key_id.clone(),
workspace_public_key_fingerprint: verification_response
.workspace_public_key_fingerprint
.clone(),
workspace_trust_id: verification_response.workspace_trust_id.clone(),
runtime_public_key_fingerprint: verification_response
.runtime_public_key_fingerprint
.clone(),
workspace_nonce: verification_response.workspace_nonce.clone(),
runtime_nonce: verification_response.runtime_nonce.clone(),
response_digest: workspace_request_body_digest(&response_body),
response: verification_response.clone(),
expires_at: verification_response.expires_at,
};
let acknowledgement_body = serde_json::to_vec(&acknowledgement).unwrap();
let acknowledgement_claims = WorkspaceCapabilityClaims {
path_and_query: runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK.to_string(),
body_digest: workspace_request_body_digest(&acknowledgement_body),
jti: "ack-token".to_string(),
..claims
};
let acknowledgement_token = issue_workspace_capability_token(
&workspace_identity.signing_key().unwrap(),
&acknowledgement_claims,
)
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::POST)
.uri(runtime_api::RUNTIME_ROUTE_VERIFICATION_ACK)
.header(
header::AUTHORIZATION,
format!("Bearer {acknowledgement_token}"),
)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(acknowledgement_body))
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
for authorization in [None, Some("Bearer malformed")] {
let mut request = Request::builder()
.method(Method::POST)
.uri(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES);
if let Some(authorization) = authorization {
request = request.header(header::AUTHORIZATION, authorization);
}
let response = app
.clone()
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
let ping_claims = WorkspaceCapabilityClaims {
operation: RUNTIME_PING_PERMISSION.to_string(),
method: "GET".to_string(),
path_and_query: "/v1/ping".to_string(),
body_digest: workspace_request_body_digest(&[]),
jti: "ping-token".to_string(),
..acknowledgement_claims
};
let ping_token = issue_workspace_capability_token(
&workspace_identity.signing_key().unwrap(),
&ping_claims,
)
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {ping_token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let mut retention_create = task_request("retention-scope");
retention_create.workspace_api = Some(WorkspaceApiRef {
workspace_id: "workspace-a".to_string(),
base_url: "https://backend.test".to_string(),
});
let retention_worker_id = retention_create.worker_id.clone();
runtime.create_worker(retention_create).unwrap();
let retention_path = format!("/v1/workers/{retention_worker_id}/retention/execute");
let retention_request = WorkerRetentionExecutionRequest {
operation_id: "retention-op".to_string(),
input_fingerprint: "fingerprint".to_string(),
archive_id: None,
workspace_id: "workspace-b".to_string(),
source_runtime_id: "runtime-a".to_string(),
worker_id: retention_worker_id,
source_created_at: "2025-01-01T00:00:00Z".to_string(),
removed_at: "2025-01-02T00:00:00Z".to_string(),
effective_profile: None,
retention_class: None,
policy_id: "policy".to_string(),
session_disposition: SessionDisposition::Purge,
diagnostics_disposition: DiagnosticsDisposition::Retain,
};
let retention_body = serde_json::to_vec(&retention_request).unwrap();
let retention_claims = WorkspaceCapabilityClaims {
operation: required_runtime_permission(&Method::POST, &retention_path)
.unwrap()
.to_string(),
method: "POST".to_string(),
path_and_query: retention_path.clone(),
worker_id: Some(retention_worker_id.to_string()),
body_digest: workspace_request_body_digest(&retention_body),
jti: "retention-scope-token".to_string(),
..ping_claims
};
let retention_token = issue_workspace_capability_token(
&workspace_identity.signing_key().unwrap(),
&retention_claims,
)
.unwrap();
let response = app
.oneshot(
Request::builder()
.method(Method::POST)
.uri(retention_path)
.header(header::AUTHORIZATION, format!("Bearer {retention_token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(retention_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let error: RuntimeHttpErrorResponse = read_json(response).await;
assert_eq!(error.error.code, "worker_not_found");
}
#[tokio::test]
async fn reenrolled_binding_trust_or_keys_require_fresh_signed_verification() {
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let fingerprint = |key: &str| {
format!(
"sha256:{}",
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(
crate::auth::decode_public_key(key).unwrap()
))
)
};
let old_trust = WorkspaceIssuerTrustRecord::from_bundle(
fn workspace_trust(identity: &RuntimeIdentityMaterial) -> WorkspaceIssuerTrustRecord {
WorkspaceIssuerTrustRecord::from_bundle(
server_api::WorkspacePublicIdentityBundle {
workspace_id: "workspace-a".into(),
backend_url: "https://backend.test".into(),
key_id: "workspace-key".into(),
key_id: identity.identity_id.clone(),
algorithm: "ed25519".into(),
public_key: workspace_identity.public_key.clone(),
public_key_fingerprint: fingerprint(&workspace_identity.public_key),
public_key: identity.public_key.clone(),
public_key_fingerprint: format!(
"sha256:{}",
crate::workspace_issuer::hex_lower(&sha2::Sha256::digest(
crate::auth::decode_public_key(&identity.public_key).unwrap()
))
),
},
"old-trust".into(),
"administrative-trust-id".into(),
1,
)
.unwrap();
let old_verification = WorkspaceRuntimeVerificationRecord {
workspace_id: old_trust.workspace_id.clone(),
.unwrap()
}
fn signed_claims(
trust: &WorkspaceIssuerTrustRecord,
method: &str,
path: &str,
body: &[u8],
) -> WorkspaceCapabilityClaims {
WorkspaceCapabilityClaims {
issuer: trust.backend_url.clone(),
issuer_workspace_id: trust.workspace_id.clone(),
issuer_key_id: trust.key_id.clone(),
issuer_public_key_fingerprint: trust.public_key_fingerprint.clone(),
runtime_id: "runtime-test".into(),
binding_id: "old-binding".into(),
workspace_key_id: old_trust.key_id.clone(),
workspace_public_key_fingerprint: old_trust.public_key_fingerprint.clone(),
workspace_trust_id: old_trust.trust_id.clone(),
runtime_public_key_fingerprint: fingerprint(&runtime_identity.public_key),
verified_at: 1,
};
for changed in ["binding", "trust", "workspace_key", "runtime_key"] {
let mut current = old_verification.clone();
let mut trust = old_trust.clone();
let mut workspace_key = workspace_identity.clone();
let mut runtime_key = runtime_identity.clone();
match changed {
"binding" => current.binding_id = "reenrolled-binding".into(),
"trust" => {
trust.trust_id = "reaccepted-trust".into();
current.workspace_trust_id = trust.trust_id.clone();
}
"workspace_key" => {
workspace_key = RuntimeIdentityMaterial::generate("new-workspace-key").unwrap();
trust.key_id = workspace_key.identity_id.clone();
trust.public_key = workspace_key.public_key.clone();
trust.public_key_fingerprint = fingerprint(&workspace_key.public_key);
current.workspace_key_id = trust.key_id.clone();
current.workspace_public_key_fingerprint = trust.public_key_fingerprint.clone();
}
"runtime_key" => {
runtime_key = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
current.runtime_public_key_fingerprint = fingerprint(&runtime_key.public_key);
}
_ => unreachable!(),
worker_id: worker_id_from_runtime_path(path),
operation: workspace_runtime_operation(
&Method::from_bytes(method.as_bytes()).unwrap(),
path,
)
.into(),
method: method.into(),
path_and_query: path.into(),
body_digest: workspace_request_body_digest(body),
iat: unix_now_i64(),
exp: unix_now_i64() + 60,
jti: uuid::Uuid::now_v7().to_string(),
}
}
fn workspace_test_auth(records: Vec<WorkspaceIssuerTrustRecord>) -> WorkspaceRuntimeHttpAuth {
WorkspaceRuntimeHttpAuth {
runtime_id: "runtime-test".into(),
verifier: WorkspaceCapabilityVerifier::new(
records,
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
}
}
async fn signed_request(
app: Router,
identity: &RuntimeIdentityMaterial,
claims: &WorkspaceCapabilityClaims,
body: Vec<u8>,
workspace: &str,
) -> Response {
let token =
issue_workspace_capability_token(&identity.signing_key().unwrap(), claims).unwrap();
app.oneshot(
Request::builder()
.method(claims.method.as_str())
.uri(&claims.path_and_query)
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, workspace)
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
}
#[tokio::test]
async fn ordinary_signed_ping_succeeds_without_handshake_and_rejects_replay() {
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let trust = workspace_trust(&identity);
let app = runtime_http_router_with_workspace_auth(
Runtime::new_memory(),
None,
workspace_test_auth(vec![trust.clone()]),
);
let claims = signed_claims(&trust, "GET", "/v1/ping", &[]);
let response = signed_request(app.clone(), &identity, &claims, vec![], "workspace-a").await;
assert_eq!(response.status(), StatusCode::OK);
let ping: runtime_api::RuntimePingResponse = read_json(response).await;
assert_eq!(ping.runtime_id, "runtime-test");
assert_eq!(ping.protocol_version, RUNTIME_HTTP_PROTOCOL_VERSION);
let response = signed_request(app, &identity, &claims, vec![], "workspace-a").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let error: RuntimeHttpErrorResponse = read_json(response).await;
assert_eq!(error.error.code, "unauthorized");
assert!(error.error.message.contains("already consumed"));
}
#[tokio::test]
async fn signed_ping_rejects_untrusted_revoked_wrong_key_signature_and_workspace_scope() {
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let other_key = RuntimeIdentityMaterial::generate("other-key").unwrap();
let trust = workspace_trust(&identity);
for case in [
"untrusted",
"revoked",
"bad_signature",
"wrong_key",
"wrong_runtime",
"wrong_operation",
"expired",
"wrong_workspace_scope",
"unsigned",
] {
let mut current = trust.clone();
if case == "revoked" {
current.state = WorkspaceIssuerTrustState::Revoked;
}
let authority = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
authority.record(old_verification.clone()).unwrap();
let records = if case == "untrusted" {
vec![]
} else {
vec![current]
};
let app = runtime_http_router_with_workspace_auth(
Runtime::new_memory(),
None,
WorkspaceRuntimeHttpAuth {
verifier: WorkspaceCapabilityVerifier::new(
vec![trust.clone()],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
signer: RuntimeVerificationSigner::from_identity(&runtime_key).unwrap(),
verifications: authority.clone(),
},
workspace_test_auth(records),
);
let claims = WorkspaceCapabilityClaims {
issuer: trust.backend_url.clone(),
issuer_workspace_id: trust.workspace_id.clone(),
issuer_key_id: trust.key_id.clone(),
issuer_public_key_fingerprint: trust.public_key_fingerprint.clone(),
trust_id: trust.trust_id.clone(),
binding_id: current.binding_id.clone(),
runtime_id: current.runtime_id.clone(),
worker_id: None,
operation: RUNTIME_PING_PERMISSION.into(),
method: "GET".into(),
path_and_query: "/v1/ping".into(),
body_digest: workspace_request_body_digest(&[]),
iat: unix_now_i64(),
exp: unix_now_i64() + 60,
jti: "stale-verification".into(),
};
for (expected, token_id) in [
(StatusCode::FORBIDDEN, "stale-verification"),
(StatusCode::OK, "fresh-verification"),
] {
if expected == StatusCode::OK {
authority.record(current.clone()).unwrap();
let mut claims = signed_claims(&trust, "GET", "/v1/ping", &[]);
match case {
"wrong_key" => claims.issuer_key_id = "untrusted-key".into(),
"wrong_runtime" => claims.runtime_id = "another-runtime".into(),
"wrong_operation" => claims.operation = "workers:create".into(),
"expired" => {
claims.iat -= 120;
claims.exp -= 120;
}
let token = issue_workspace_capability_token(
&workspace_key.signing_key().unwrap(),
&WorkspaceCapabilityClaims {
jti: token_id.into(),
..claims.clone()
_ => {}
}
let response = if case == "unsigned" {
app.oneshot(
Request::builder()
.uri("/v1/ping")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
} else {
signed_request(
app,
if case == "bad_signature" {
&other_key
} else {
&identity
},
&claims,
vec![],
if case == "wrong_workspace_scope" {
"workspace-b"
} else {
"workspace-a"
},
)
.unwrap();
let response = app
.clone()
.oneshot(
Request::builder()
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
expected,
"{changed}: {}",
String::from_utf8_lossy(&body)
);
if expected == StatusCode::FORBIDDEN {
let error: RuntimeHttpErrorResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(error.error.code, "workspace_runtime_verification_stale");
}
}
.await
};
let status = response.status();
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
status,
if case == "wrong_workspace_scope" {
StatusCode::FORBIDDEN
} else {
StatusCode::UNAUTHORIZED
},
"{case}: {}",
String::from_utf8_lossy(&bytes)
);
let error: RuntimeHttpErrorResponse = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
error.error.code,
if case == "wrong_workspace_scope" {
"runtime_ping_workspace_scope_mismatch"
} else {
"unauthorized"
},
"{case}"
);
}
}
#[tokio::test]
async fn ordinary_signed_workdir_operation_succeeds_without_evidence_and_rejects_changed_body()
{
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("hello.txt"), "hello").unwrap();
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let trust = workspace_trust(&identity);
let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound(
Workdir::new("wd-1"),
temp.path().to_path_buf(),
temp.path().to_path_buf(),
SharedScope::new(Scope::writable(temp.path()).unwrap()),
WorkdirSessionCapabilities::ALL,
));
let state = RuntimeHttpState {
runtime: Runtime::new_memory(),
local_token: None,
workspace_auth: Some(Arc::new(workspace_test_auth(vec![trust.clone()]))),
workdir_sessions: Arc::new(Mutex::new(HashMap::from([(
"session-1".into(),
RuntimeHttpWorkdirSession {
owner: RuntimeWorkspaceScope::new("workspace-a", "https://backend.test"),
session,
},
)]))),
ssh_keyscan_program: Arc::new(PathBuf::from("ssh-keyscan")),
};
let app = Router::new()
.route(
"/v1/workdir-sessions/{session_id}/operations",
post(run_workdir_session_operation),
)
.with_state(state.clone())
.layer(middleware::from_fn_with_state(state, require_runtime_auth));
let body = serde_json::to_vec(&WorkdirSessionOperationRequest {
operation: WorkdirSessionOperation::Stat(StatRequest {
path: WorkdirPath::new("hello.txt").unwrap(),
}),
})
.unwrap();
let claims = signed_claims(
&trust,
"POST",
"/v1/workdir-sessions/session-1/operations",
&body,
);
let response = signed_request(app.clone(), &identity, &claims, body, "workspace-a").await;
assert_eq!(response.status(), StatusCode::OK);
let result: WorkdirSessionOperationResult = read_json(response).await;
assert!(matches!(result, WorkdirSessionOperationResult::Stat(_)));
let claims = WorkspaceCapabilityClaims {
jti: uuid::Uuid::now_v7().to_string(),
..claims
};
let response = signed_request(app, &identity, &claims, b"{}".to_vec(), "workspace-a").await;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let error: RuntimeHttpErrorResponse = read_json(response).await;
assert!(error.error.message.contains("request body"));
}
#[tokio::test]
async fn removed_handshake_routes_are_not_network_aliases() {
let identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let trust = workspace_trust(&identity);
let app = runtime_http_router_with_workspace_auth(
Runtime::new_memory(),
None,
workspace_test_auth(vec![trust.clone()]),
);
for path in [
"/v1/workspace-runtime-verification/challenge",
"/v1/workspace-runtime-verification/acknowledgement",
] {
let claims = signed_claims(&trust, "POST", path, b"{}");
let response = signed_request(
app.clone(),
&identity,
&claims,
b"{}".to_vec(),
"workspace-a",
)
.await;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
@@ -4630,8 +4276,7 @@ mod tests {
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
signer: RuntimeVerificationSigner::from_identity(&identity).unwrap(),
verifications: Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default()),
runtime_id: identity.identity_id.clone(),
},
);
(runtime, app)
@@ -164,7 +164,7 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
.state
.workspace_auth
.as_ref()
.map(|auth| auth.signer.runtime_id().trim())
.map(|auth| auth.runtime_id.trim())
.filter(|runtime_id| !runtime_id.is_empty())
.ok_or_else(|| {
runtime_api::RuntimeApiError::new(
+126 -54
View File
@@ -27,11 +27,11 @@ use worker_runtime::http_server::{
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
use worker_runtime::working_directory::RuntimeGitMaterializer;
use worker_runtime::workspace_issuer::{
FileWorkspaceClaimReplayProtection, FileWorkspaceRuntimeVerificationAuthority,
MAX_WORKSPACE_ISSUER_TRUST_RECORDS, RuntimeVerificationSigner, WorkspaceCapabilityVerifier,
WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord,
WorkspaceIssuerTrustState, add_workspace_issuer_trust, replace_workspace_issuer_trust,
revoke_workspace_issuer_trust, validate_workspace_issuer_trust_records,
FileWorkspaceClaimReplayProtection, MAX_WORKSPACE_ISSUER_TRUST_RECORDS,
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation,
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, add_workspace_issuer_trust,
replace_workspace_issuer_trust, revoke_workspace_issuer_trust,
validate_workspace_issuer_trust_records,
};
use worker_runtime::workspace_request::RuntimeWorkspaceRequestClient;
use worker_runtime::{Runtime, RuntimeOptions};
@@ -972,7 +972,7 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
.map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?;
if migrated {
// Commit the revoked migration before exposing any issuer authority.
// Persist the validated migration without changing saved trust permissions.
write_runtime_auth_file(path, &auth)?;
}
Ok(auth)
@@ -1025,25 +1025,15 @@ fn load_workspace_runtime_http_auth(
let Some(identity) = auth.identity else {
return Ok(None);
};
if auth.workspace_issuers.is_empty() {
return Ok(None);
}
let replay_path = runtime_auth_path(config).with_extension("workspace-replay.json");
let verifier = WorkspaceCapabilityVerifier::new(
auth.workspace_issuers,
Arc::new(FileWorkspaceClaimReplayProtection::new(replay_path)),
)
.map_err(|error| ProcessError::auth(format!("invalid Workspace issuer trust: {error}")))?;
let signer = RuntimeVerificationSigner::from_identity(&identity)
.map_err(|error| ProcessError::auth(format!("invalid Runtime identity: {error}")))?;
let verifications_path =
runtime_auth_path(config).with_extension("workspace-verifications.json");
Ok(Some(WorkspaceRuntimeHttpAuth {
verifier,
signer,
verifications: Arc::new(FileWorkspaceRuntimeVerificationAuthority::new(
verifications_path,
)),
runtime_id: identity.identity_id,
}))
}
@@ -1407,6 +1397,34 @@ mod tests {
assert_eq!(std::fs::read(root.join("runtime.json")).unwrap(), before);
}
#[test]
fn runtime_identity_with_no_trusted_workspace_does_not_disable_request_authentication() {
let temp = tempfile::tempdir().unwrap();
let config = ProcessConfig {
fs_root: Some(temp.path().to_path_buf()),
..ProcessConfig::default().unwrap()
};
let path = runtime_auth_path(&config);
write_runtime_auth_file(
&path,
&RuntimeAuthFile {
identity: Some(RuntimeIdentityMaterial::generate("runtime-test").unwrap()),
..RuntimeAuthFile::default()
},
)
.unwrap();
// Obsolete evidence is neither a startup prerequisite nor live authority.
let evidence = path.with_extension("workspace-verifications.json");
std::fs::write(&evidence, "obsolete evidence, not even valid JSON").unwrap();
let auth = load_workspace_runtime_http_auth(&config).unwrap().unwrap();
assert_eq!(auth.runtime_id, "runtime-test");
assert!(!auth.verifier.has_active_workspace_issuer("workspace-a"));
assert_eq!(
std::fs::read_to_string(&evidence).unwrap(),
"obsolete evidence, not even valid JSON"
);
}
#[test]
fn workspace_issuer_trust_cli_persists_across_reload_and_writes_private_mode() {
use server_api::WorkspacePublicIdentityBundle;
@@ -1454,38 +1472,6 @@ mod tests {
let first = read_runtime_auth_file(&path).unwrap();
assert_eq!(first.workspace_issuers.len(), 1);
assert!(!first.workspace_issuers[0].trust_id.is_empty());
// `trust-workspace show` serializes this persisted record. Owner
// registration must forward that exact enrollment ID, not derive one
// from the key fingerprint or an initial counter.
let shown = serde_json::to_value(&first.workspace_issuers[0]).unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("remote-runtime").unwrap();
let mut registration_wire = serde_json::json!({
"public_bundle": {
"identity_id": runtime_identity.identity_id,
"public_key": runtime_identity.public_key,
},
"endpoint": "https://runtime.example.test",
"workspace_trust_id": shown["trust_id"],
});
let registration: server_api::CreateRemoteRuntimeRequest =
serde_json::from_value(registration_wire.clone()).unwrap();
assert_eq!(
registration.workspace_trust_id,
first.workspace_issuers[0].trust_id
);
assert_ne!(
registration.workspace_trust_id,
first.workspace_issuers[0].public_key_fingerprint
);
registration_wire
.as_object_mut()
.unwrap()
.remove("workspace_trust_id");
assert!(
serde_json::from_value::<server_api::CreateRemoteRuntimeRequest>(registration_wire)
.is_err()
);
run_trust_workspace_command(VecDeque::from([
"add".to_string(),
"--bundle".to_string(),
@@ -1645,9 +1631,14 @@ mod tests {
assert_eq!(error, "invalid trust-workspace list arguments");
}
#[test]
fn pre_id_issuer_migration_retains_history_without_restoring_authority() {
#[tokio::test]
async fn pre_id_issuer_migration_preserves_saved_permission_for_ordinary_signed_ping() {
use sha2::{Digest as _, Sha256};
use tower::ServiceExt;
use worker_runtime::workspace_issuer::{
WorkspaceCapabilityClaims, issue_workspace_capability_token,
workspace_request_body_digest,
};
let identity = RuntimeIdentityMaterial::generate("WK-legacy").unwrap();
let public_key = decode_public_key(&identity.public_key).unwrap();
@@ -1680,16 +1671,97 @@ mod tests {
std::fs::write(&path, toml::to_string(&document).unwrap()).unwrap();
let migrated = read_runtime_auth_file(&path).unwrap();
assert_eq!(
migrated.workspace_issuers[0].state,
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
let expected_state = if state == "active" {
WorkspaceIssuerTrustState::Active
} else {
WorkspaceIssuerTrustState::Revoked
};
let record = &migrated.workspace_issuers[0];
assert_eq!(record.state, expected_state);
assert_eq!(record.workspace_id, "workspace-1");
assert_eq!(record.backend_url, "https://backend.example.test");
assert_eq!(record.key_id, "WK-legacy");
assert_eq!(record.public_key, identity.public_key);
assert_eq!(record.public_key_fingerprint, fingerprint);
assert_eq!(record.registered_at_unix, 10);
assert_eq!(record.updated_at_unix, 20);
// Migration is durably saved by the loader itself, not just by a later write.
assert_eq!(read_runtime_auth_file(&path).unwrap(), migrated);
let claims = WorkspaceCapabilityClaims {
issuer: record.backend_url.clone(),
issuer_workspace_id: record.workspace_id.clone(),
issuer_key_id: record.key_id.clone(),
issuer_public_key_fingerprint: record.public_key_fingerprint.clone(),
runtime_id: "runtime-test".into(),
worker_id: None,
operation: worker_runtime::http_server::RUNTIME_PING_PERMISSION.into(),
method: "GET".into(),
path_and_query: "/v1/ping".into(),
body_digest: workspace_request_body_digest(&[]),
iat: i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(),
exp: i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap() + 60,
jti: uuid::Uuid::now_v7().to_string(),
};
let token = issue_workspace_capability_token(&identity.signing_key().unwrap(), &claims)
.unwrap();
let app = worker_runtime::http_server::runtime_http_router_with_workspace_auth(
Runtime::new_memory(), None, WorkspaceRuntimeHttpAuth {
runtime_id: "runtime-test".into(),
verifier: WorkspaceCapabilityVerifier::new(migrated.workspace_issuers.clone(),
Arc::new(worker_runtime::workspace_issuer::InMemoryWorkspaceClaimReplayProtection::default())).unwrap(),
},
);
let response = app
.oneshot(
axum::http::Request::builder()
.uri("/v1/ping")
.header(axum::http::header::AUTHORIZATION, format!("Bearer {token}"))
.header(
worker_runtime::http_server::RUNTIME_WORKSPACE_SCOPE_HEADER,
"workspace-1",
)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let status = response.status();
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
assert_eq!(
status,
if state == "active" {
axum::http::StatusCode::OK
} else {
axum::http::StatusCode::UNAUTHORIZED
},
"{state}: {}",
String::from_utf8_lossy(&body)
);
if state == "active" {
let ping: runtime_api::RuntimePingResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(ping.runtime_id, "runtime-test");
} else {
let error: worker_runtime::http_server::RuntimeHttpErrorResponse =
serde_json::from_slice(&body).unwrap();
assert_eq!(error.error.code, "unauthorized");
assert!(error.error.message.contains("revoked"));
}
assert_eq!(migrated.archived_legacy_issuers.len(), 1);
let archived: toml::Value =
toml::from_str(&migrated.archived_legacy_issuers[0]).unwrap();
assert_eq!(archived, original_record);
write_runtime_auth_file(&path, &migrated).unwrap();
assert_eq!(read_runtime_auth_file(&path).unwrap(), migrated);
if state == "active" {
// A current saved Revoked record stays revoked even if its archived
// pre-ID source was Active. Never guess revocation provenance.
let mut saved_revoked = migrated.clone();
saved_revoked.workspace_issuers[0].state = WorkspaceIssuerTrustState::Revoked;
write_runtime_auth_file(&path, &saved_revoked).unwrap();
assert_eq!(read_runtime_auth_file(&path).unwrap(), saved_revoked);
}
}
}
File diff suppressed because it is too large Load Diff
+124 -6
View File
@@ -379,6 +379,7 @@ pub(crate) struct CompactWorkerInterceptor {
pub on_warning: Option<Arc<dyn Fn(String) + Send + Sync>>,
warning_sent: AtomicBool,
last_remaining_tokens: AtomicU64,
request_shape: Option<(String, agen::tool_server::ToolServerHandle)>,
}
impl CompactWorkerInterceptor {
@@ -397,9 +398,21 @@ impl CompactWorkerInterceptor {
on_warning,
warning_sent: AtomicBool::new(false),
last_remaining_tokens: AtomicU64::new(max_input_tokens),
request_shape: None,
}
}
pub(crate) fn with_request_shape(
mut self,
system_prompt: String,
tools: agen::tool_server::ToolServerHandle,
) -> Self {
// Features install after engine configuration. This shared handle sees
// the final installed tool definitions at the actual request boundary.
self.request_shape = Some((system_prompt, tools));
self
}
fn maybe_emit_warning(&self, remaining: u64) -> Option<Item> {
let warning_threshold = self.finish_warning_remaining_tokens;
let reserve_threshold = self.final_reserve_tokens;
@@ -412,7 +425,12 @@ impl CompactWorkerInterceptor {
let message = format!(
"compact worker context budget is low ({remaining}/{} tokens remaining). \
Stop broad exploration now, read only if absolutely necessary, then call \
`write_summary` with the final structured summary.",
`write_summary` with the final structured summary. Preserve ongoing goals and \
user directives from the full initial summary and segment. Compress duplicate \
explanations and old work details first, not scope, prohibitions, completion \
conditions or exception-reporting duties. Apply explicit later changes/retractions \
and retain uncertainty; recovery procedures and recent progress do not replace \
the original request.",
self.max_input_tokens
);
if let Some(cb) = self.on_warning.as_ref() {
@@ -433,17 +451,38 @@ impl<A: Send + Sync> Interceptor<A> for CompactWorkerInterceptor {
let context = context.items;
let records = self.usage_tracker.records();
let estimate = agen::token_counter::total_tokens(context, &records);
if estimate.tokens > self.max_input_tokens {
// Before the first provider measurement, include the system and tool
// shape as well as *all* mandatory summary/input bytes. Later exact
// input measurements already include that shape; do not double charge.
let shape_tokens = if records.is_empty() {
self.request_shape.as_ref().map_or(0, |(system, tools)| {
let tool_bytes = serde_json::to_vec(&tools.tool_definitions_sorted())
.map(|bytes| bytes.len())
.unwrap_or(0);
estimate_tokens(system.len().saturating_add(tool_bytes))
})
} else {
0
};
let input_tokens = estimate.tokens.saturating_add(shape_tokens);
if input_tokens > self.max_input_tokens {
return Ok(PreRequestAction::Cancel(format!(
"compact worker input occupancy exceeded {} tokens",
"compact worker input occupancy (including mandatory full initial summary) exceeded {} tokens; no summary was truncated",
self.max_input_tokens
)));
}
let remaining = self.max_input_tokens.saturating_sub(estimate.tokens);
let remaining = self.max_input_tokens.saturating_sub(input_tokens);
self.last_remaining_tokens
.store(remaining, Ordering::Release);
if let Some(item) = self.maybe_emit_warning(remaining) {
let warning_tokens =
agen::token_counter::total_tokens(std::slice::from_ref(&item), &[]).tokens;
if input_tokens.saturating_add(warning_tokens) > self.max_input_tokens {
return Ok(PreRequestAction::Cancel(
"compact worker input occupancy cannot fit budget warning alongside mandatory full input; no summary was truncated".into(),
));
}
self.usage_tracker.note_request(context.len() + 1);
return Ok(PreRequestAction::ContinueWith(vec![item]));
}
@@ -542,6 +581,85 @@ mod tests {
));
}
#[tokio::test]
async fn compact_worker_warning_policy_keeps_user_intent_and_fails_if_warning_cannot_fit() {
let interceptor =
CompactWorkerInterceptor::new(Arc::new(UsageTracker::new()), 100, 100, 0, None);
let mut context = vec![Item::user_message("full input")];
let result = interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap();
assert!(
matches!(result, PreRequestAction::Cancel(message) if message.contains("no summary was truncated"))
);
let interceptor =
CompactWorkerInterceptor::new(Arc::new(UsageTracker::new()), 1000, 1000, 0, None);
let result = interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap();
let PreRequestAction::ContinueWith(items) = result else {
panic!("expected persisted warning")
};
let warning = items[0].as_text().unwrap();
for policy in [
"full initial summary and segment",
"prohibitions",
"completion conditions",
"exception-reporting duties",
"changes/retractions",
"uncertainty",
"Compress duplicate",
] {
assert!(warning.contains(policy), "missing warning policy: {policy}");
}
}
#[tokio::test]
async fn compact_worker_initial_request_counts_system_shape_but_measurements_do_not_double_count()
{
let tracker = Arc::new(UsageTracker::new());
let interceptor = CompactWorkerInterceptor::new(tracker.clone(), 100, 0, 0, None)
.with_request_shape(
"system".repeat(100),
agen::tool_server::ToolServerHandle::default(),
);
let mut context = vec![Item::user_message("full input")];
assert!(matches!(
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Cancel(_)
));
tracker.note_request(context.len());
tracker.record_usage(&make_usage(50));
assert!(matches!(
interceptor
.pre_llm_request(PreLlmRequestContext::<()> {
invocation: Default::default(),
items: &mut context,
history: &[],
})
.await
.unwrap(),
PreRequestAction::Continue
));
}
#[tokio::test]
async fn compact_worker_interceptor_warns_before_hard_cap() {
let tracker = Arc::new(UsageTracker::new());
@@ -549,8 +667,8 @@ mod tests {
let captured = warnings.clone();
let interceptor = CompactWorkerInterceptor::new(
tracker.clone(),
150,
60,
500,
410,
20,
Some(Arc::new(move |message| {
captured.lock().unwrap().push(message);
@@ -560,6 +560,7 @@ where
attempt.instance_id, attempt.attempt_id
)),
max_turns: Some(12),
compaction_budget: None,
engine_configurator: None,
features,
required_tools: &[
@@ -560,6 +560,7 @@ impl MemoryLifecycleTask {
.extraction
.worker_max_turns
.or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
compaction_budget: None,
engine_configurator: None,
features,
required_tools: &[
@@ -376,6 +376,7 @@ impl SubjektivSurfaceLifecycleTask {
input,
cache_key: None,
max_turns: Some(EDITOR_MAX_TURNS),
compaction_budget: None,
engine_configurator: None,
features,
required_tools: &["SubmitMemorySurface"],
+8
View File
@@ -68,6 +68,7 @@ pub(crate) struct InternalWorkerSpec {
) + Send,
>,
>,
pub compaction_budget: Option<crate::compact::worker::CompactWorkerInterceptor>,
pub features: FeatureRegistryBuilder,
pub required_tools: &'static [&'static str],
pub authority: InternalWorkerAuthority,
@@ -118,6 +119,7 @@ where
cache_key,
max_turns,
engine_configurator,
compaction_budget,
features,
required_tools,
authority,
@@ -171,6 +173,7 @@ where
identity: identity.clone(),
history_entries: 0,
})?;
worker.install_compaction_budget(compaction_budget);
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_single_workdir_session(Some(session));
@@ -666,6 +669,7 @@ pub(crate) async fn spawn_internal_worker_session(
cache_key,
max_turns,
engine_configurator,
compaction_budget,
features,
required_tools,
authority,
@@ -701,6 +705,7 @@ pub(crate) async fn spawn_internal_worker_session(
.map_err(|source| InternalWorkerSessionError::Build {
message: source.to_string(),
})?;
worker.install_compaction_budget(compaction_budget);
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_single_workdir_session(Some(session));
@@ -762,6 +767,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
cache_key,
max_turns,
engine_configurator,
compaction_budget,
features,
required_tools,
authority,
@@ -794,6 +800,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
.map_err(|source| InternalWorkerSessionError::Build {
message: source.to_string(),
})?;
worker.install_compaction_budget(compaction_budget);
worker.disable_manifest_lifecycle_features();
if let Some(session) = inherited_workdir_session {
worker.bind_single_workdir_session(Some(session));
@@ -1442,6 +1449,7 @@ permission = "write"
input: "input".to_string(),
cache_key: Some("internal-test".to_string()),
max_turns: Some(1),
compaction_budget: None,
engine_configurator: None,
features: FeatureRegistryBuilder::new(),
required_tools,
+40 -4
View File
@@ -74,6 +74,7 @@ async fn call_hook_before_deadline<E: HookEventKind>(
}
pub(crate) struct WorkerInterceptor {
compaction_budget: Option<Arc<crate::compact::worker::CompactWorkerInterceptor>>,
registry: Arc<HookRegistry>,
compact_state: Option<Arc<CompactState>>,
/// Shared view of the cumulative UsageRecord timeline. Used with the
@@ -157,6 +158,7 @@ impl WorkerInterceptor {
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
) -> Self {
Self {
compaction_budget: None,
registry,
compact_state,
usage_history,
@@ -175,6 +177,14 @@ impl WorkerInterceptor {
}
}
pub(crate) fn with_compaction_budget(
mut self,
budget: Option<Arc<crate::compact::worker::CompactWorkerInterceptor>>,
) -> Self {
self.compaction_budget = budget;
self
}
pub(crate) fn with_notification_committer(
mut self,
committer: Option<Arc<dyn DurableNotificationCommitter>>,
@@ -552,6 +562,8 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
&self,
context: PreLlmRequestContext<'_, SessionHistoryMetadata>,
) -> InterceptorResult<PreRequestAction> {
let context_invocation = context.invocation;
let context_history = context.history;
let context = context.items;
let initial_tokens = self.estimated_tokens(context);
if let Some(action) =
@@ -616,7 +628,7 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
.expect("pending hook system-item queue poisoned"),
);
self.attach_prompt_provenance(&mut system_items);
let appended_items: Vec<Item> = system_items
let mut appended_items: Vec<Item> = system_items
.iter()
.map(SystemItem::to_history_item)
.collect();
@@ -652,10 +664,28 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
});
}
if let Some(usage_tracker) = self.usage_tracker.as_ref() {
usage_tracker.note_request(effective_context.len());
// Internal compaction safety must survive the normal Worker interceptor
// installation. Run it on the complete request after durable hook additions;
// Engine persists any returned warning through its normal append path.
if let Some(budget) = self.compaction_budget.as_ref() {
let mut budget_context = effective_context.into_owned();
match budget
.pre_llm_request(PreLlmRequestContext {
invocation: context_invocation,
items: &mut budget_context,
history: context_history,
})
.await?
{
PreRequestAction::Continue => {}
PreRequestAction::ContinueWith(warnings) => appended_items.extend(warnings),
action => return Ok(action),
}
}
if system_items.is_empty() {
if let Some(usage_tracker) = self.usage_tracker.as_ref() {
usage_tracker.note_request(context.len() + appended_items.len());
}
if appended_items.is_empty() {
return Ok(PreRequestAction::Continue);
}
Ok(match self.commit_system_items(&system_items) {
@@ -700,6 +730,12 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
if let Some(reason) = denials.into_iter().next() {
return Ok(HookPreToolAction::Deny(reason).into_worker_action(summary.call_id.clone()));
}
if let Some(budget) = self.compaction_budget.as_ref() {
match budget.pre_tool_call(info).await? {
PreToolAction::Continue => {}
action => return Ok(action),
}
}
self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed);
Ok(PreToolAction::Continue)
}
+1
View File
@@ -221,6 +221,7 @@ impl PreparedInternalJob {
run_id: uuid::Uuid::now_v7(),
},
max_turns: engine_policy.max_turns.map(|n| n.get()),
compaction_budget: None,
engine_configurator: Some(Box::new(move |engine| {
crate::apply_worker_manifest(engine, &engine_policy);
})),
+57 -1
View File
@@ -725,7 +725,9 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance
}
WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
WorkerHistoryProvenance::DerivedSummary => SessionEntryProvenance::DerivedSummary,
WorkerHistoryProvenance::CompactionSummary | WorkerHistoryProvenance::DerivedSummary => {
SessionEntryProvenance::DerivedSummary
}
WorkerHistoryProvenance::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
}
}
@@ -878,6 +880,60 @@ mod tests {
);
}
#[test]
fn host_summary_remains_private_to_compaction_not_observation_or_memory_evidence() {
let entries = [
(
Item::system_message("host-summary-secret"),
WorkerHistoryProvenance::CompactionSummary,
),
(
Item::system_message("other-derived-secret"),
WorkerHistoryProvenance::DerivedSummary,
),
(
Item::system_message("raw-system-secret"),
WorkerHistoryProvenance::LegacyUnknown,
),
(
Item::reasoning("reasoning-secret"),
WorkerHistoryProvenance::LegacyUnknown,
),
]
.into_iter()
.map(|(item, origin)| crate::session_history::history_entry(item, origin))
.collect::<Vec<_>>();
let refs = entries
.iter()
.map(|entry| SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id))
.collect::<Vec<_>>();
let capture = SessionCapture::from_history_entries("segment", entries);
assert!(capture.overview().is_empty());
assert!(
capture
.search(&SearchOptions {
query: "secret".into(),
..SearchOptions::default()
})
.is_empty()
);
assert!(
capture
.read(ReadSelector::EntryRange([0, 3]), ReadOptions::default())
.entries
.is_empty()
);
for reference in refs {
assert!(capture.evidence_for(reference.as_str()).is_none());
assert!(
capture
.read(ReadSelector::Id(reference.as_str()), ReadOptions::default())
.entries
.is_empty()
);
}
}
#[test]
fn overview_contains_user_and_assistant_only() {
let view = SessionCapture::new(
+100 -1
View File
@@ -17,6 +17,8 @@ pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
pub(crate) const COMPACTION_SUMMARY_HEADER: &str = "[Compacted context summary]\n\n";
#[cfg(test)]
pub(crate) fn test_logged_history_entry(item: impl Into<Item>) -> LoggedHistoryEntry {
LoggedHistoryEntry {
@@ -108,8 +110,32 @@ pub(crate) fn restore_history_entries(
let mut history = Vec::new();
for entry in entries {
match entry {
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
LogEntry::AnnotatedSegmentStart {
history: seed,
compacted_from,
..
} => {
history = seed.iter().map(from_logged).collect();
// Older Host compactions always put this DerivedSummary system item
// first in the replacement seed. Only that trusted construction boundary
// may interpret the marker; never infer origin from appended/pasted text.
if compacted_from.is_some()
&& let Some(first) = history.first_mut()
&& first.annotation.origin == WorkerHistoryProvenance::DerivedSummary
&& matches!(
&first.item,
Item::Message {
role: agen::Role::System,
..
}
)
&& first
.item
.as_text()
.is_some_and(|text| text.starts_with(COMPACTION_SUMMARY_HEADER))
{
first.annotation.origin = WorkerHistoryProvenance::CompactionSummary;
}
}
LogEntry::AnnotatedUserInput { history: input, .. } => {
history.extend(input.iter().map(from_logged))
@@ -144,6 +170,79 @@ mod tests {
use protocol::Segment;
use session_store::LogEntry;
#[test]
fn restore_recognizes_only_legacy_host_compaction_seed_summary() {
let summary = history_entry(
Item::system_message(format!("{COMPACTION_SUMMARY_HEADER}host summary")),
WorkerHistoryProvenance::DerivedSummary,
);
for compacted in [false, true] {
let seed = LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id: SessionId::now_v7(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![to_logged_history_entry(&summary)],
forked_from: None,
compacted_from: compacted.then(|| session_store::SegmentOrigin {
segment_id: SegmentId::now_v7(),
at_turn_index: 0,
}),
};
let appended = LogEntry::AnnotatedUserInput {
ts: 1,
segments: vec![],
extensions: vec![],
history: vec![to_logged_history_entry(&summary)],
};
let restored = restore_history_entries(
SessionId::now_v7(),
SegmentId::now_v7(),
&[seed, appended],
)
.unwrap();
assert_eq!(
restored[0].annotation.origin,
if compacted {
WorkerHistoryProvenance::CompactionSummary
} else {
WorkerHistoryProvenance::DerivedSummary
}
);
assert_eq!(
restored[1].annotation.origin,
WorkerHistoryProvenance::DerivedSummary
);
assert_eq!(restored[0].annotation.entry_id, summary.annotation.entry_id);
}
for item in [
Item::user_message(format!("{COMPACTION_SUMMARY_HEADER}pasted summary")),
Item::system_message("[Session TaskStore snapshot] tasks"),
] {
let seed = LogEntry::AnnotatedSegmentStart {
ts: 0,
session_id: SessionId::now_v7(),
system_prompt: None,
config: RequestConfig::default(),
history: vec![to_logged_history_entry(&history_entry(
item,
WorkerHistoryProvenance::DerivedSummary,
))],
forked_from: None,
compacted_from: Some(session_store::SegmentOrigin {
segment_id: SegmentId::now_v7(),
at_turn_index: 0,
}),
};
let restored =
restore_history_entries(SessionId::now_v7(), SegmentId::now_v7(), &[seed]).unwrap();
assert_eq!(
restored[0].annotation.origin,
WorkerHistoryProvenance::DerivedSummary
);
}
}
#[test]
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
let session_id = SessionId::now_v7();
+205 -20
View File
@@ -2801,6 +2801,7 @@ pub struct Worker<C: LlmClient, St: Store> {
/// manifest-derived lifecycle Features before controller startup.
manifest_lifecycle_features_enabled: bool,
interceptor_installed: bool,
compaction_service_budget: Option<Arc<crate::compact::worker::CompactWorkerInterceptor>>,
/// Shared compaction state (present when threshold is configured).
compact_state: Option<Arc<CompactState>>,
/// Per-LLM-request Usage tracker. Always present after construction.
@@ -3145,6 +3146,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
feature_invocations: FeatureInvocationRegistry::default(),
manifest_lifecycle_features_enabled: true,
interceptor_installed: false,
compaction_service_budget: None,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
@@ -4221,6 +4223,24 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.callback_socket.as_ref()
}
pub(crate) fn install_compaction_budget(
&mut self,
budget: Option<crate::compact::worker::CompactWorkerInterceptor>,
) {
self.assert_hooks_open();
self.compaction_service_budget = budget.map(|budget| {
Arc::new(
budget.with_request_shape(
self.engine()
.get_system_prompt()
.unwrap_or_default()
.to_owned(),
self.engine().tool_server_handle(),
),
)
});
}
// --- Hook registration ---
fn assert_hooks_open(&self) {
@@ -4316,6 +4336,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
self.log_writer.clone(),
self.pending_committed_history.clone(),
)
.with_compaction_budget(self.compaction_service_budget.clone())
.with_notification_committer(self.notification_committer.clone())
.with_usage_tracker(self.usage_tracker.clone())
.with_metrics_tracker(self.metrics_tracker.clone())
@@ -6610,7 +6631,15 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
let history_entries = self.session.history().entries();
let retain_from = cut.index.min(history_entries.len());
let retained_history_entries = history_entries[retain_from..].to_vec();
let initial_summary = initial_compaction_summary(history_entries)?;
// The old summary is consumed in full by the compaction service even when
// the cut falls before it. The replacement summary supersedes it: retaining
// it as well would duplicate inherited context on every compact.
let retained_history_entries = history_entries[retain_from..]
.iter()
.filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary)
.cloned()
.collect::<Vec<_>>();
let mut retained_items = retained_history_entries
.iter()
.map(|entry| entry.item.clone())
@@ -6623,7 +6652,11 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
project_compaction_attachments(&mut retained_items, default_workdir_alias.as_deref());
// The compactor explores summaries, text details, and ViewImage call arguments. It
// never needs durable image bodies; relevant images are nominated with add_reference.
let mut entries_to_summarise = history_entries[..retain_from].to_vec();
let mut entries_to_summarise = history_entries[..retain_from]
.iter()
.filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary)
.cloned()
.collect::<Vec<_>>();
project_compactor_capture_attachments(&mut entries_to_summarise);
let items_to_summarise = entries_to_summarise
.iter()
@@ -6688,11 +6721,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
Vec::new()
};
// Input text fed to the compact worker. Includes the default
// references, current TaskStore snapshot, current TaskStore snapshot, and the (pruned) conversation text.
// The full Host summary is mandatory input, separate from the bounded
// prefix overview and current TaskStore snapshot.
let task_snapshot_text = self.task_feature.snapshot_text();
let summary_input = build_summary_input(
&items_to_summarise,
initial_summary.as_deref(),
&default_refs,
Some(task_snapshot_text.as_str()),
SummaryInputOptions {
@@ -6804,12 +6838,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
input: summary_input.text.clone(),
cache_key: Some(self.segment_id().to_string()),
max_turns: worker_max_turns,
compaction_budget: Some(interceptor),
engine_configurator: Some(Box::new(move |engine| {
let tracker = tracker_for_engine;
engine.on_usage(move |event| {
tracker.record_usage(event);
});
engine.set_interceptor(interceptor);
})),
features,
required_tools,
@@ -6869,7 +6903,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
Some(
"You have not called `write_summary` yet. Deliver the structured \
summary now (Completed Tasks / Active Task / Key Decisions / \
User Directives / Current Work) and nominate any files the next \
User Directives / Current Work), preserving ongoing goals and user \
directives from the initial summary and segment. Nominate any files the next \
session needs with `mark_read_required` for UTF-8 text or \
`add_reference` for images, binary files, and on-demand reads."
.to_string(),
@@ -6901,11 +6936,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
.ok_or(WorkerError::CompactSummaryMissing)?;
let mut summary_tokens = estimate_text_tokens(summary_text.len());
if summary_max_tokens > 0 && summary_tokens > summary_max_tokens {
let prompt = format!(
"Your `write_summary` output is too large (≈{summary_tokens} tokens; max \
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
same five sections but making it concise. Target ≈{summary_target_tokens} tokens."
);
let prompt =
summary_shrink_prompt(summary_tokens, summary_max_tokens, summary_target_tokens);
handle
.send(prompt)
.await
@@ -7014,8 +7046,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
);
let mut compact_introduced_system_messages =
Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize);
let summary_message =
Item::system_message(format!("[Compacted context summary]\n\n{summary_text}"));
let summary_message = Item::system_message(format!(
"{}{summary_text}",
crate::session_history::COMPACTION_SUMMARY_HEADER
));
compact_introduced_system_messages.push(summary_message.clone());
compact_introduced_system_messages.extend(auto_read_messages.iter().cloned());
if let Some(msg) = reference_message.as_ref() {
@@ -7072,8 +7106,19 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
let compacted_history_entries = new_history
.iter()
.cloned()
.map(|item| {
if let Some((offset, original)) = original_entries[original_cursor..]
.enumerate()
.map(|(index, item)| {
if index == 0 {
HistoryEntry::new(
item,
new_history_metadata(
WorkerHistoryProvenance::CompactionSummary,
Some(SessionHistoryDerivation {
sources: derived_sources.clone(),
}),
),
)
} else if let Some((offset, original)) = original_entries[original_cursor..]
.iter()
.enumerate()
.find(|(_, original)| original.item == item)
@@ -7489,6 +7534,7 @@ where
feature_invocations: FeatureInvocationRegistry::default(),
manifest_lifecycle_features_enabled: true,
interceptor_installed: false,
compaction_service_budget: None,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
@@ -7828,6 +7874,7 @@ where
feature_invocations: FeatureInvocationRegistry::default(),
manifest_lifecycle_features_enabled: false,
interceptor_installed: false,
compaction_service_budget: None,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
@@ -7957,6 +8004,7 @@ where
feature_invocations: FeatureInvocationRegistry::default(),
manifest_lifecycle_features_enabled: true,
interceptor_installed: false,
compaction_service_budget: None,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
@@ -8342,6 +8390,7 @@ where
feature_invocations: FeatureInvocationRegistry::default(),
manifest_lifecycle_features_enabled: true,
interceptor_installed: false,
compaction_service_budget: None,
compact_state: None,
usage_tracker: Arc::new(UsageTracker::new()),
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
@@ -8740,6 +8789,61 @@ fn build_related_file_message(
)))
}
fn summary_shrink_prompt(
summary_tokens: u64,
summary_max_tokens: u64,
summary_target_tokens: u64,
) -> String {
format!(
"Your `write_summary` output is too large (≈{summary_tokens} tokens; max \
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
same five sections. Compress duplicate explanations and old work details \
first; preserve ongoing goals and user directives from the initial summary \
and segment, including scope, prohibitions, completion conditions and \
exception-reporting duties. Apply explicit later changes/retractions and \
retain uncertainty; do not replace the original request with recovery \
procedures or recent progress. Target ≈{summary_target_tokens} tokens."
)
}
/// Select only the Host's typed summary, never a marker in ordinary content.
/// Invalid/duplicate Host summaries are a broken seed contract, not permission
/// to silently omit inherited instructions.
fn initial_compaction_summary(
entries: &[HistoryEntry<SessionHistoryMetadata>],
) -> Result<Option<String>, WorkerError> {
let mut summaries = entries
.iter()
.filter(|entry| entry.annotation.origin == WorkerHistoryProvenance::CompactionSummary);
let Some(entry) = summaries.next() else {
return Ok(None);
};
if summaries.next().is_some() {
return Err(WorkerError::InvalidState(
"multiple Host compaction summaries in active history".into(),
));
}
if !matches!(
&entry.item,
Item::Message {
role: agen::Role::System,
..
}
) {
return Err(WorkerError::InvalidState(
"Host compaction summary must be a system message".into(),
));
}
let body = entry
.item
.as_text()
.and_then(|text| text.strip_prefix(crate::session_history::COMPACTION_SUMMARY_HEADER))
.ok_or_else(|| {
WorkerError::InvalidState("Host compaction summary header is missing".into())
})?;
Ok(Some(body.to_string()))
}
#[derive(Debug, Clone, Copy)]
struct SummaryInputOptions {
overview_target_tokens: u64,
@@ -8756,11 +8860,12 @@ struct SummaryInputBuild {
deadline_fallback_used: bool,
}
/// Build the compact worker's input: default-reference instructions,
/// the list of recently-touched files, task snapshot,
/// and a bounded overview rather than a prefix-wide transcript.
/// Build mandatory full Host-summary input plus a bounded prefix overview,
/// recently-touched files and current tasks. Overview budgets never apply to
/// the summary; the complete request is checked by the compaction interceptor.
fn build_summary_input(
items: &[Item],
initial_summary: Option<&str>,
default_refs: &[PathBuf],
task_snapshot: Option<&str>,
options: SummaryInputOptions,
@@ -8786,10 +8891,20 @@ fn build_summary_input(
out.push_str(&format!(
"Summarise this session into a structured summary of about {} tokens and \
nominate files the next session needs. The conversation below is a \
bounded overview/index, not the full transcript. Use tools to inspect \
bounded overview/index, not the full transcript. A separately supplied initial \
compaction summary is complete, mandatory source material. Preserve ongoing \
user goals and directives from it and from this segment's user messages; \
confirm important wording with exploration rather than reconstructing the \
request from recent progress. Retained tail entries remain verbatim in the \
next session and are outside this prefix overview. Use tools to inspect \
current files when deciding auto-read/reference output.\n\n",
options.summary_target_tokens
));
if let Some(summary) = initial_summary {
out.push_str("## Initial compaction summary (full text)\n");
out.push_str(summary);
out.push_str("\n\n## End initial compaction summary\n\n");
}
if !default_refs.is_empty() {
out.push_str(
"These files were touched recently in this session. Use `read_file` \
@@ -8808,7 +8923,8 @@ fn build_summary_input(
out.push_str(
"## Current Session TaskStore\n\
This is the full current task list. Use it as source material for the \
summary, especially active (pending/inprogress) tasks, but do not edit tasks \
summary, especially active (pending/inprogress) tasks, but not as a \
replacement for user goals, directives or completion conditions. Do not edit tasks \
from the compact worker.\n",
);
out.push_str(task_snapshot);
@@ -10103,6 +10219,7 @@ mod build_summary_prompt_tests {
fn test_summary_input(items: &[Item]) -> String {
build_summary_input(
items,
None,
&[],
None,
SummaryInputOptions {
@@ -10115,6 +10232,72 @@ mod build_summary_prompt_tests {
.text
}
#[test]
fn compact_prompt_and_shrink_policy_prioritize_continuing_user_intent() {
// The builtin resource is the default model-visible compaction contract.
let prompt = include_str!("../../../resources/prompts/internal/compact_system.md");
for policy in [
"initial compaction summary in full",
"segment's user messages",
"prohibitions",
"completion conditions",
"explicit changes or retractions",
"Preserve uncertainty",
"retained tail stays verbatim",
"TaskStore",
] {
assert!(prompt.contains(policy), "missing prompt policy: {policy}");
}
let shrink = summary_shrink_prompt(2_000, 1_000, 500);
for policy in [
"initial summary",
"and segment",
"Compress duplicate",
"prohibitions",
"completion conditions",
"exception-reporting duties",
"changes/retractions",
"uncertainty",
"recovery",
] {
assert!(shrink.contains(policy), "missing shrink policy: {policy}");
}
let input = test_summary_input(&[Item::user_message("keep original scope")]);
assert!(input.contains("Preserve ongoing user goals and directives"));
assert!(input.contains("Retained tail entries remain verbatim"));
}
#[test]
fn malformed_or_duplicate_host_summaries_fail_instead_of_omitting_context() {
use crate::session_history::history_entry;
let valid = history_entry(
Item::system_message(format!(
"{}body",
crate::session_history::COMPACTION_SUMMARY_HEADER
)),
WorkerHistoryProvenance::CompactionSummary,
);
assert_eq!(
initial_compaction_summary(std::slice::from_ref(&valid))
.unwrap()
.as_deref(),
Some("body")
);
assert!(initial_compaction_summary(&[valid.clone(), valid]).is_err());
for item in [
Item::user_message("pasted"),
Item::system_message("missing marker"),
] {
assert!(
initial_compaction_summary(&[history_entry(
item,
WorkerHistoryProvenance::CompactionSummary
)])
.is_err()
);
}
}
#[test]
fn retained_images_become_on_demand_related_files() {
let mut items = vec![
@@ -10215,6 +10398,7 @@ mod build_summary_prompt_tests {
let items = vec![Item::user_message("x".repeat(4_000))];
let built = build_summary_input(
&items,
None,
&[],
None,
SummaryInputOptions {
@@ -10234,6 +10418,7 @@ mod build_summary_prompt_tests {
let items = vec![Item::user_message("x".repeat(4_000))];
let built = build_summary_input(
&items,
None,
&[],
None,
SummaryInputOptions {
+726 -1
View File
@@ -133,6 +133,7 @@ struct MockClient {
responses: Arc<Vec<Vec<LlmEvent>>>,
call_count: Arc<AtomicUsize>,
observer: Arc<Mutex<Option<CallObserver>>>,
requests: Arc<Mutex<Vec<Request>>>,
}
impl MockClient {
@@ -141,9 +142,14 @@ impl MockClient {
responses: Arc::new(responses),
call_count: Arc::new(AtomicUsize::new(0)),
observer: Arc::new(Mutex::new(None)),
requests: Arc::new(Mutex::new(Vec::new())),
}
}
fn requests(&self) -> Vec<Request> {
self.requests.lock().unwrap().clone()
}
fn set_observer(&self, observer: impl Fn(usize) + Send + Sync + 'static) {
*self.observer.lock().unwrap() = Some(Arc::new(observer));
}
@@ -157,9 +163,10 @@ impl LlmClient for MockClient {
async fn stream(
&self,
_request: Request,
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
self.requests.lock().unwrap().push(request);
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
if let Some(observer) = self.observer.lock().unwrap().clone() {
observer(count);
@@ -2065,3 +2072,721 @@ async fn controller_compact_method_publishes_progress_and_clear() {
let command = protocol::WorkerCommandEnvelope::new(2);
let _ = handle.send(Method::Shutdown { command }).await;
}
const INITIAL_SUMMARY_HEADING: &str = "## Initial compaction summary (full text)\n";
const OVERVIEW_HEADING: &str = "## Conversation overview/index\n";
const SUMMARY_MARKER: &str = "[Compacted context summary]\n\n";
// These tests prove transport and durable replacement policy, not whether a live
// model preserves meaning. All write_summary outputs below are predetermined.
struct SummaryInputFixture {
worker: Worker<MockClient, TestStore>,
_store_dir: tempfile::TempDir,
}
impl SummaryInputFixture {
async fn new(manifest_toml: &str, client: MockClient) -> Self {
let store_dir = tempfile::tempdir().unwrap();
let store = CombinedStore::new(
FsStore::new(store_dir.path()).unwrap(),
FsWorkerStore::new(store_dir.path().join("pods")).unwrap(),
);
let engine =
Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated(
client,
);
let mut worker = Worker::new(
worker::WorkerManifest::from_toml(manifest_toml).unwrap(),
engine,
store,
worker::WorkerWorkspaceContext::no_workspace(),
worker::WorkerFilesystemAuthority::None,
worker::Scope::empty(),
)
.await
.unwrap();
worker.enable_worker_metadata_write_through().unwrap();
worker.attach_log_writer(Arc::new(worker.log_writer_handle()));
Self {
worker,
_store_dir: store_dir,
}
}
// Seed the persisted boundary, then exercise the public rewind API's shared
// typed history restore path. No production client or global allocation is
// needed. This is restoration coverage, not a cold Runtime restart test.
async fn restore_seed(
&mut self,
history: Vec<session_store::LoggedHistoryEntry>,
compacted: bool,
) {
self.worker
.run_text("fixture rewind boundary")
.await
.unwrap();
let session_id = self.worker.session_id();
let segment_id = self.worker.segment_id();
let mut records = self
.worker
.store()
.read_all(session_id, segment_id)
.unwrap();
let start = records
.iter_mut()
.find(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart { .. }))
.unwrap();
match start {
LogEntry::AnnotatedSegmentStart {
history: seed,
compacted_from,
..
} => {
*seed = history;
*compacted_from = compacted.then(|| session_store::SegmentOrigin {
segment_id: session_store::new_segment_id(),
at_turn_index: 0,
});
}
_ => unreachable!(),
}
self.worker
.store()
.create_segment(session_id, segment_id, &records)
.unwrap();
let (head, targets) = self.worker.list_rewind_targets().unwrap();
self.worker
.rewind_to(targets[0].id.clone(), head)
.await
.unwrap();
}
}
fn summary_entry(
item: Item,
origin: session_store::LoggedSessionHistoryOrigin,
) -> session_store::LoggedHistoryEntry {
let mut entry = annotated(item);
entry.metadata.origin = origin;
entry
}
fn long_summary(label: &str) -> String {
format!(
"## Completed Tasks\n{label}: investigated the transport boundary.\n\n\
## Active Task\nKeep the original user's completion conditions.\n{}\n\
## Key Decisions\nDo not infer provenance from pasted markers.\n\n\
## User Directives\n{label}_LOAD_BEARING_SENTINEL_AT_END\n\n\
## Current Work\nContinue the requested implementation.",
"Historical constraints must remain explicit, including the original acceptance criteria.\n"
.repeat(70)
)
}
fn recent_history(count: usize) -> Vec<session_store::LoggedHistoryEntry> {
(0..count)
.flat_map(|index| {
[
annotated(Item::user_message(format!(
"recent-user-{index}: {}",
"recent progress is not a replacement for the original directive. ".repeat(40)
))),
annotated(Item::assistant_message(format!("recent-assistant-{index}"))),
]
})
.collect()
}
fn first_compactor_input(request: &Request) -> String {
request
.items
.iter()
.find_map(|item| match item {
Item::Message {
role: agen::Role::User,
..
} => item
.as_text()
.filter(|text| text.contains(OVERVIEW_HEADING)),
_ => None,
})
.expect("the first compactor request must carry its overview input")
.to_string()
}
fn assert_full_initial_summary(input: &str, summary: &str) {
let heading_at = input
.find(INITIAL_SUMMARY_HEADING)
.expect("full summary heading");
let overview_at = input.find(OVERVIEW_HEADING).expect("overview heading");
let summary_at = heading_at + INITIAL_SUMMARY_HEADING.len();
assert!(summary_at < overview_at);
assert!(
input[summary_at..].starts_with(summary),
"summary must be verbatim"
);
assert_eq!(
input.matches(summary).count(),
1,
"summary must not be duplicated"
);
assert!(!input[overview_at..].contains(summary));
let opening = summary.chars().take(100).collect::<String>();
assert!(
!input[overview_at..].contains(&opening),
"overview must not carry even a clipped summary"
);
assert!(!input[overview_at..].contains("LOAD_BEARING_SENTINEL_AT_END"));
}
fn replacement_seed(
worker: &Worker<MockClient, TestStore>,
) -> Vec<session_store::LoggedHistoryEntry> {
worker
.store()
.read_all(worker.session_id(), worker.segment_id())
.unwrap()
.into_iter()
.find_map(|entry| match entry {
LogEntry::AnnotatedSegmentStart { history, .. } => Some(history),
_ => None,
})
.expect("replacement SegmentStart")
}
#[tokio::test]
async fn full_initial_summary_survives_small_overview_budgets_and_both_retention_extremes() {
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
let inherited = long_summary("original");
assert!(inherited.chars().count() > 4_000);
for retained in [0, 1_000_000] {
for (warning, deadline) in [(0, 0), (1, 0), (1, 1)] {
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
"[compaction]",
&format!(
"[compaction]\noverview_target_tokens = 1\noverview_warning_tokens = {warning}\noverview_deadline_tokens = {deadline}"
),
);
let client = MockClient::new(vec![
single_text_events("fixture"),
write_summary_tool_use_events("replace", "new summary"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
let mut seed = vec![summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}{inherited}")),
CompactionSummary,
)];
let recent = recent_history(40);
seed.extend(recent.clone());
fixture.restore_seed(seed, true).await;
let (tx, mut rx) = broadcast::channel::<Event>(64);
fixture.worker.attach_alerter(worker::Alerter::new(tx));
fixture.worker.compact(retained).await.unwrap();
let requests = observed.requests();
assert_eq!(requests.len(), 3);
let input = first_compactor_input(&requests[1]);
assert_full_initial_summary(&input, &inherited);
let alerts = drain(&mut rx);
let has_alert = |snippet: &str| {
alerts.iter().any(|event| {
matches!(event, Event::Alert(alert)
if alert.source == protocol::AlertSource::Compactor
&& alert.level == protocol::AlertLevel::Warn
&& alert.message.contains(snippet))
})
};
assert_eq!(has_alert("larger than expected"), warning != 0);
assert_eq!(has_alert("using coarse fallback"), deadline != 0);
let replacement = replacement_seed(&fixture.worker);
assert_eq!(
replacement
.iter()
.filter(|entry| entry.metadata.origin == CompactionSummary)
.count(),
1
);
assert!(replacement.iter().all(|entry| {
!Item::from(entry.item.clone())
.as_text()
.is_some_and(|text| text.contains(&inherited))
}));
for entry in &recent {
assert_eq!(
replacement
.iter()
.any(|retained_entry| retained_entry == entry),
retained != 0,
"ordinary retained entries must keep their item and metadata"
);
}
let metrics = session_metrics::read_session_metrics(
fixture.worker.store(),
fixture.worker.session_id(),
)
.unwrap();
let overview_tokens = metrics
.iter()
.find(|record| record.metric.name == "compact.overview_tokens")
.and_then(|record| record.metric.value)
.unwrap();
assert!(
overview_tokens < inherited.len().div_ceil(4) as f64,
"overview accounting must exclude the inherited full summary"
);
}
}
}
#[tokio::test]
async fn pasted_markers_and_other_derived_context_remain_ordinary_overview_material() {
use session_store::LoggedSessionHistoryOrigin::{
CompactionSummary, DerivedSummary, LegacyUnknown,
};
for retained in [0, 1_000_000] {
let client = MockClient::new(vec![
single_text_events("fixture"),
write_summary_tool_use_events("replace", "replacement"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await;
let ordinary = vec![
summary_entry(
Item::system_message("[Session TaskStore snapshot]\nactive task"),
DerivedSummary,
),
summary_entry(
Item::system_message("auto-read file source material"),
DerivedSummary,
),
summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}pasted system marker")),
LegacyUnknown,
),
summary_entry(
Item::user_message(format!("{SUMMARY_MARKER}pasted user marker")),
LegacyUnknown,
),
summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}later derived marker")),
DerivedSummary,
),
];
fixture.restore_seed(ordinary.clone(), true).await;
assert!(
fixture
.worker
.session_history()
.entries()
.iter()
.all(|entry| entry.annotation.origin != CompactionSummary)
);
fixture.worker.compact(retained).await.unwrap();
let input = first_compactor_input(&observed.requests()[1]);
assert!(!input.contains(INITIAL_SUMMARY_HEADING));
let overview = input.split_once(OVERVIEW_HEADING).unwrap().1;
let replacement = replacement_seed(&fixture.worker);
for entry in &ordinary {
let text = Item::from(entry.item.clone())
.as_text()
.unwrap()
.to_string();
assert_eq!(
overview.contains(&text),
retained == 0,
"ordinary overview source: {text}"
);
assert_eq!(
replacement
.iter()
.any(|retained_entry| retained_entry == entry),
retained != 0,
"other derived context and pasted markers must follow ordinary retention"
);
}
}
}
#[tokio::test]
async fn restored_legacy_summary_requires_compacted_lineage_first_position_origin_role_and_marker()
{
use session_store::LoggedSessionHistoryOrigin::{
CompactionSummary, DerivedSummary, LegacyUnknown,
};
let inherited = long_summary("legacy");
// Each false case lacks one trusted construction condition; marker text
// alone must never promote ordinary context into inherited Host material.
for case in [
"trusted",
"no-lineage",
"not-first",
"unknown-origin",
"user-role",
"no-marker",
] {
let client = MockClient::new(vec![
single_text_events("fixture"),
write_summary_tool_use_events("replace", "replacement"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await;
let text = if case == "no-marker" {
inherited.clone()
} else {
format!("{SUMMARY_MARKER}{inherited}")
};
let item = if case == "user-role" {
Item::user_message(text)
} else {
Item::system_message(text)
};
let origin = if case == "unknown-origin" {
LegacyUnknown
} else {
DerivedSummary
};
let candidate = summary_entry(item, origin);
let candidate_id = candidate.metadata.entry_id.clone();
let mut history = vec![candidate];
if case == "not-first" {
history.insert(
0,
annotated(Item::system_message("unrelated first system context")),
);
}
history.extend(recent_history(20));
fixture.restore_seed(history, case != "no-lineage").await;
let restored = fixture
.worker
.session_history()
.entries()
.iter()
.find(|entry| entry.annotation.entry_id == candidate_id)
.unwrap();
assert_eq!(
restored.annotation.origin == CompactionSummary,
case == "trusted",
"{case}"
);
// A restore-time upgrade is an in-memory interpretation of the trusted
// seed; it must not silently rewrite the historical durable record.
assert!(
replacement_seed(&fixture.worker)
.iter()
.all(|entry| entry.metadata.origin != CompactionSummary)
);
fixture.worker.compact(0).await.unwrap();
let input = first_compactor_input(&observed.requests()[1]);
if case == "trusted" {
assert_full_initial_summary(&input, &inherited);
} else {
assert!(!input.contains(INITIAL_SUMMARY_HEADING), "{case}");
}
}
}
#[tokio::test]
async fn three_chained_compactions_transport_each_complete_predecessor_without_retaining_old_summaries()
{
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
let original_goal =
"CHAIN_ORIGINAL_USER_GOAL: finish only after all explicit acceptance conditions hold";
// Every predetermined output keeps the original directive. The assertions
// prove that complete predecessors reach later requests, not that a model
// chose to preserve this directive on its own.
let summaries = (0..3)
.map(|index| {
format!(
"{}\n{original_goal}",
long_summary(&format!("generation-{index}"))
)
})
.collect::<Vec<_>>();
let initial = format!("{}\n{original_goal}", long_summary("initial"));
let mut responses = vec![single_text_events("seed response")];
for (index, summary) in summaries.iter().enumerate() {
responses.push(single_text_events(
"acknowledge new directive and temporary recovery",
));
responses.push(write_summary_tool_use_events(
&format!("summary-{index}"),
summary,
));
responses.push(single_text_events("done"));
}
let client = MockClient::new(responses);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await;
fixture
.restore_seed(
vec![summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}{initial}")),
CompactionSummary,
)],
true,
)
.await;
let original_session = fixture.worker.session_id();
let mut predecessor = fixture.worker.segment_id();
for (index, summary) in summaries.iter().enumerate() {
let new_directive = format!(
"NEW_USER_DIRECTIVE_{index}: do not merge. Temporarily recover the interrupted work and collect SubWorkers; this does not retract the original goal."
);
fixture.worker.run_text(&new_directive).await.unwrap();
let first_request = observed.requests().len();
fixture.worker.compact(0).await.unwrap();
let input = first_compactor_input(&observed.requests()[first_request]);
let prior = if index == 0 {
&initial
} else {
&summaries[index - 1]
};
assert_full_initial_summary(&input, prior);
assert!(input.contains(original_goal));
assert!(input.contains(&new_directive));
let seed = replacement_seed(&fixture.worker);
let inherited = seed
.iter()
.filter(|entry| entry.metadata.origin == CompactionSummary)
.collect::<Vec<_>>();
assert_eq!(inherited.len(), 1);
assert_eq!(
Item::from(inherited[0].item.clone()).as_text().unwrap(),
format!("{SUMMARY_MARKER}{summary}")
);
for old in &summaries[..index] {
assert!(
fixture
.worker
.history()
.iter()
.all(|item| !item.as_text().is_some_and(|text| text.contains(old)))
);
}
let records = fixture
.worker
.store()
.read_all(original_session, fixture.worker.segment_id())
.unwrap();
assert!(
records
.iter()
.any(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart {
compacted_from: Some(origin), ..
} if origin.segment_id == predecessor))
);
let restored = session_store::restore(
fixture.worker.store(),
original_session,
fixture.worker.segment_id(),
)
.unwrap();
assert!(restored.history.iter().any(|item| {
item.as_text()
.is_some_and(|text| text == format!("{SUMMARY_MARKER}{summary}"))
}));
predecessor = fixture.worker.segment_id();
}
assert_eq!(observed.requests().len(), 10);
}
#[tokio::test]
async fn inherited_summary_counts_against_hard_input_limit_and_failure_does_not_activate() {
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
"[compaction]",
"[compaction]\nworker_context_max_tokens = 10000\nfinish_warning_remaining_tokens = 0\nfinal_reserve_tokens = 0\noverview_target_tokens = 1",
);
for include_summary in [false, true] {
let client = MockClient::new(vec![
single_text_events("fixture"),
write_summary_tool_use_events("replace", "replacement"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
let summary =
"Preserve the user's goal and every explicit completion condition.\n".repeat(3_000);
assert!(
agen::token_counter::total_tokens(&[Item::user_message(&summary)], &[]).tokens > 10_000
);
let mut history = Vec::new();
if include_summary {
history.push(summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}{summary}")),
CompactionSummary,
));
}
history.extend(recent_history(20));
fixture.restore_seed(history, true).await;
let source = fixture.worker.segment_id();
let source_history = fixture.worker.history();
let result = fixture.worker.compact(0).await;
if include_summary {
let error = result.expect_err("mandatory input over hard cap must fail, not truncate");
assert!(
matches!(error, WorkerError::CompactCancelled)
|| error.to_string().contains("input occupancy"),
"unexpected hard-cap failure: {error:?}"
);
assert_eq!(
observed.requests().len(),
1,
"no compactor request may cross the hard cap"
);
assert_eq!(fixture.worker.segment_id(), source);
assert_eq!(fixture.worker.history(), source_history);
assert_eq!(
fixture
.worker
.store()
.read_by_name("test-worker")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(source)
);
assert_eq!(
fixture
.worker
.store()
.list_segments(fixture.worker.session_id())
.unwrap()
.len(),
1
);
} else {
result.expect("the same bounded overview without inherited input fits the cap");
assert_eq!(observed.requests().len(), 3);
assert_ne!(fixture.worker.segment_id(), source);
}
}
}
#[tokio::test]
async fn summary_output_still_over_hard_max_after_rewrite_fails_without_activation() {
let oversized = long_summary("oversized");
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
"[compaction]",
"[compaction]\nsummary_target_tokens = 50\nsummary_max_tokens = 100",
);
let client = MockClient::new(vec![
single_text_events("seed"),
write_summary_tool_use_events("first-output", &oversized),
single_text_events("done"),
write_summary_tool_use_events("rewrite-output", &oversized),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
fixture.worker.run_text("user goal").await.unwrap();
let source = fixture.worker.segment_id();
let history = fixture.worker.history();
let error = fixture.worker.compact(0).await.unwrap_err();
assert!(matches!(
error,
WorkerError::CompactSummaryTooLarge { max: 100, .. }
));
assert_eq!(fixture.worker.segment_id(), source);
assert_eq!(fixture.worker.history(), history);
assert_eq!(
fixture
.worker
.store()
.read_by_name("test-worker")
.unwrap()
.unwrap()
.active
.unwrap()
.segment_id,
Some(source)
);
assert_eq!(
fixture
.worker
.store()
.list_segments(fixture.worker.session_id())
.unwrap()
.len(),
1
);
assert!(
observed
.requests()
.iter()
.any(|request| request.items.iter().any(|item| item
.as_text()
.is_some_and(|text| text.contains("Rewrite it now with `write_summary`"))))
);
}
#[tokio::test]
async fn budget_warning_is_durable_and_keeps_full_summary_in_first_model_request() {
use session_store::LoggedSessionHistoryOrigin::CompactionSummary;
let manifest = MANUAL_ONLY_MANIFEST_TOML.replace(
"[compaction]",
"[compaction]\nworker_context_max_tokens = 10000\nfinish_warning_remaining_tokens = 9999\nfinal_reserve_tokens = 9999",
);
let client = MockClient::new(vec![
single_text_events("fixture"),
write_summary_tool_use_events("summary", "replacement"),
single_text_events("done"),
]);
let observed = client.clone();
let mut fixture = SummaryInputFixture::new(&manifest, client).await;
let inherited = long_summary("budget-warning");
fixture
.restore_seed(
vec![summary_entry(
Item::system_message(format!("{SUMMARY_MARKER}{inherited}")),
CompactionSummary,
)],
true,
)
.await;
let (tx, mut rx) = broadcast::channel::<Event>(128);
fixture.worker.attach_alerter(worker::Alerter::new(tx));
fixture.worker.compact(0).await.unwrap();
let requests = observed.requests();
assert_full_initial_summary(&first_compactor_input(&requests[1]), &inherited);
let warning = requests[1]
.items
.iter()
.filter_map(Item::as_text)
.find(|text| text.starts_with("[Compact worker budget warning]"))
.expect("normal internal Worker must preserve the budget interceptor warning");
assert!(warning.contains("full initial summary and segment"));
assert!(warning.contains("changes/retractions"));
assert!(
requests[2]
.items
.iter()
.filter_map(Item::as_text)
.any(|text| text == warning),
"warning must remain in history, not be a transient request insertion"
);
assert!(
drain(&mut rx)
.iter()
.any(|event| matches!(event, Event::Alert(alert)
if alert.message.contains("compact worker context budget is low")))
);
}
@@ -32,7 +32,8 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
tx.execute_batch(
"CREATE TEMP TABLE repository_key_cutover (
workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL,
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL,
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL,
created_at_seconds INTEGER NOT NULL, created_at_subsec_nanos INTEGER NOT NULL,
PRIMARY KEY(workspace_id,kind,resource_id,legacy_counter));",
)?;
for (table, kind, id, fingerprint) in [
@@ -68,15 +69,20 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
for (workspace, resource, legacy_counter, fingerprint, created) in rows {
let mut statement = tx.prepare("SELECT operation_id FROM repository_secret_operations WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?;
let matches = statement
.query_map(params![workspace, kind, resource, legacy_counter, created], |r| {
r.get::<_, String>(0)
})?
.query_map(
params![workspace, kind, resource, legacy_counter, created],
|r| r.get::<_, String>(0),
)?
.collect::<rusqlite::Result<Vec<_>>>()?;
if matches.len() != 1 || matches[0].is_empty() {
return Err(Error::Store("Repository key cutover requires one unambiguous mutation receipt per stored key".into()));
}
let created_time = frozen_repository_timestamp(
&created,
&format!("{table}.created_at (workspace {workspace}, resource {resource})"),
)?;
tx.execute(
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7)",
"INSERT INTO repository_key_cutover VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
params![
workspace,
kind,
@@ -84,11 +90,26 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
legacy_counter,
matches[0],
fingerprint,
created
created_time.timestamp(),
created_time.timestamp_subsec_nanos()
],
)?;
}
}
// Candidate copying uses an INNER JOIN: reject corrupt source rows before
// they can disappear and evade the destination foreign-key check.
let orphaned_candidates: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM workdir_create_credential_candidates c
LEFT JOIN workdir_create_operations o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id
WHERE o.operation_id IS NULL)",
[],
|row| row.get(0),
)?;
if orphaned_candidates {
return Err(Error::Store(
"Repository key cutover found credential candidates without a Workdir create operation".into(),
));
}
// Current rows must agree with their pointed-to historical key, not merely
// resolve to a nonempty receipt. FK checks alone cannot prove this identity.
let inconsistent: bool = tx.query_row("SELECT EXISTS (
@@ -148,8 +169,17 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (workspace, id, legacy_counter, purpose, algorithm, nonce, ciphertext, created, operation) in
secrets
for (
workspace,
id,
legacy_counter,
purpose,
algorithm,
nonce,
ciphertext,
created,
operation,
) in secrets
{
if algorithm != "aes-256-gcm-v1" {
return Err(Error::Store(
@@ -233,8 +263,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
SELECT o.workspace_id,o.operation_id FROM workdir_create_operations_v86 o
WHERE o.state='succeeded'
AND NOT EXISTS(SELECT 1 FROM workdir_create_credential_revision_retentions_v86 r WHERE r.workspace_id=o.workspace_id AND r.operation_id=o.operation_id)
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));
INSERT INTO repository_secret_legacy_receipts
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id));")?;
prepare_repository_cutover_times(&tx)?;
tx.execute_batch("INSERT INTO repository_secret_legacy_receipts
SELECT e.workspace_id,e.operation_id,e.request_fingerprint,e.resource_kind,e.resource_id,e.result_revision,e.created_at,e.mutation_kind,
CASE WHEN e.mutation_kind IN ('credential_created','host_trust_created') THEN 0
WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') AND e.result_revision>1 THEN e.result_revision-1
@@ -244,8 +275,9 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
WHERE a.workspace_id=o.workspace_id AND a.resource_id=o.resource_id AND a.revision=o.result_revision AND a.created_at=o.created_at
AND ((o.resource_kind='credential' AND a.kind LIKE 'credential_%') OR (o.resource_kind='host_trust' AND a.kind LIKE 'host_trust_%'))
HAVING count(*)=1) AS mutation_kind FROM repository_secret_operations_v86 o) e
JOIN repository_cutover_times t ON t.kind='receipt' AND t.workspace_id=e.workspace_id AND t.operation_id=e.operation_id
LEFT JOIN repository_key_cutover m ON m.workspace_id=e.workspace_id AND m.kind=e.resource_kind AND m.resource_id=e.resource_id
AND m.created_at<=e.created_at
AND (m.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
AND m.legacy_counter=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END;
INSERT INTO workdir_create_operations
@@ -257,12 +289,14 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END
FROM workdir_create_operations_v86 o
LEFT JOIN workdir_terminal_archive_cutover a ON a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND c.created_at<=o.updated_at
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND h.created_at<=o.updated_at;
LEFT JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.operation_id
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND (c.created_at_seconds,c.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND (h.created_at_seconds,h.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos);
INSERT INTO workdir_create_credential_candidates
SELECT c.workspace_id,c.operation_id,c.ordinal,c.role,c.credential_id,m.fingerprint
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id LEFT JOIN repository_key_cutover m
ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND m.created_at<=o.updated_at
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id
JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=o.workspace_id AND t.operation_id=o.operation_id
LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND (m.created_at_seconds,m.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a WHERE a.workspace_id=c.workspace_id AND a.operation_id=c.operation_id);
INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision;")?;
let missing:i64=tx.query_row("SELECT count(*) FROM workdir_create_operations WHERE (credential_id IS NOT NULL AND credential_fingerprint IS NULL) OR (host_trust_id IS NOT NULL AND host_trust_fingerprint IS NULL)",[],|r|r.get(0))?;
@@ -313,7 +347,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
] {
tx.execute_batch(&format!("DROP TABLE {table}_v86;"))?;
}
tx.execute_batch("DROP TABLE repository_key_cutover;
tx.execute_batch("DROP TABLE repository_key_cutover; DROP TABLE repository_cutover_times;
CREATE INDEX idx_repository_secret_audit_workspace_created ON repository_secret_audit_events(workspace_id,created_at,event_id);
CREATE INDEX idx_repository_ssh_credentials_workspace_status ON repository_ssh_credentials(workspace_id,status,credential_id);
CREATE INDEX idx_repository_ssh_host_trusts_workspace_host ON repository_ssh_host_trusts(workspace_id,hostname,port);")?;
@@ -360,6 +394,109 @@ BEGIN SELECT RAISE(ABORT, 'runtime_removal_in_progress'); END;
result.and(restore)
}
// Schema 86 used RFC3339 for Repository secrets and epoch-millisecond text for
// registry operations. Compare typed instants, never their persisted spelling.
fn frozen_repository_timestamp(
value: &str,
context: &str,
) -> Result<chrono::DateTime<chrono::Utc>> {
if let Ok(timestamp) = chrono::DateTime::parse_from_rfc3339(value) {
return Ok(timestamp.with_timezone(&chrono::Utc));
}
if let Ok(millis) = value.parse::<i64>()
&& let Some(timestamp) = chrono::DateTime::from_timestamp_millis(millis)
{
return Ok(timestamp);
}
Err(Error::Store(format!(
"Repository key migration found invalid timestamp in {context}"
)))
}
fn prepare_repository_cutover_times(tx: &rusqlite::Transaction<'_>) -> Result<()> {
// These temporary normalized values never rewrite audit history or the
// original timestamps used to identify receipts and encrypted envelopes.
// Seconds + nanoseconds also preserve sub-millisecond ordering and offsets.
tx.execute_batch(
"CREATE TEMP TABLE repository_cutover_times (
kind TEXT NOT NULL, workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL,
seconds INTEGER NOT NULL, subsec_nanos INTEGER NOT NULL,
PRIMARY KEY(kind,workspace_id,operation_id));",
)?;
for (kind, context, query) in [
(
"receipt",
"repository_secret_operations.created_at",
"SELECT workspace_id,operation_id,created_at FROM repository_secret_operations_v86",
),
(
"workdir",
"workdir_create_operations.updated_at",
"SELECT o.workspace_id,o.operation_id,o.updated_at FROM workdir_create_operations_v86 o
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a
WHERE a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id)
AND (o.credential_id IS NOT NULL OR o.host_trust_id IS NOT NULL OR
EXISTS(SELECT 1 FROM workdir_create_credential_candidates_v86 c
WHERE c.workspace_id=o.workspace_id AND c.operation_id=o.operation_id))",
),
] {
let rows = tx
.prepare(query)?
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
for (workspace, operation, value) in rows {
let timestamp = frozen_repository_timestamp(
&value,
&format!("{context} (workspace {workspace}, operation {operation})"),
)?;
tx.execute(
"INSERT INTO repository_cutover_times VALUES(?1,?2,?3,?4,?5)",
params![
kind,
workspace,
operation,
timestamp.timestamp(),
timestamp.timestamp_subsec_nanos()
],
)?;
}
}
// Refuse missing or newer/reused keys with a domain error before a NULL
// fingerprint reaches a NOT NULL column. Archived successes are excluded:
// they retain opaque history, not authority to retry with a current key.
let missing: Option<(String, String)> = tx.query_row(
"SELECT r.workspace_id,r.operation_id FROM (
SELECT workspace_id,operation_id,'credential' AS kind,credential_id AS resource_id,credential_revision AS legacy_counter
FROM workdir_create_operations_v86 WHERE credential_id IS NOT NULL
UNION
SELECT workspace_id,operation_id,'host_trust',host_trust_id,host_trust_revision
FROM workdir_create_operations_v86 WHERE host_trust_id IS NOT NULL
UNION
SELECT workspace_id,operation_id,'credential',credential_id,credential_revision
FROM workdir_create_credential_candidates_v86
) r
JOIN repository_cutover_times t ON t.kind='workdir' AND t.workspace_id=r.workspace_id AND t.operation_id=r.operation_id
LEFT JOIN repository_key_cutover k ON k.workspace_id=r.workspace_id AND k.kind=r.kind
AND k.resource_id=r.resource_id AND k.legacy_counter=r.legacy_counter
AND (k.created_at_seconds,k.created_at_subsec_nanos)<=(t.seconds,t.subsec_nanos)
WHERE k.operation_id IS NULL LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
).optional()?;
if let Some((workspace, operation)) = missing {
return Err(Error::Store(format!(
"Workdir SSH snapshot is missing key identity evidence at or before its timestamp (workspace {workspace}, operation {operation})"
)));
}
Ok(())
}
// Preserve every SQLite field and NULL verbatim as typed JSON. Frozen archives
// are never read by credential lookup, lease construction, or a live CAS.
fn frozen_repository_json_rows(
@@ -32,12 +32,12 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
let end = start + frozen[start..].find(end).unwrap();
conn.execute_batch(&frozen[start..end]).unwrap();
}
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','created');
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'actor','2026-01-01T00:00:00.000Z');
INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_revision,config_projection_digest,working_directory_id,state,created_at,updated_at,credential_id,credential_revision)
VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','created','created','key',7);
VALUES('space','create-workdir','intent','repo','runtime',3,'projection','workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z','key',7);
INSERT INTO workdir_create_credential_candidates VALUES('space','create-workdir',0,'primary','key',7);
INSERT INTO workdir_create_credential_revision_retentions VALUES('space','create-workdir',0,'key',7);
CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
@@ -59,11 +59,300 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
&mut ciphertext,
)
.unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'created')",params![purpose,nonce.as_slice(),ciphertext]).unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,?1,'aes-256-gcm-v1',?2,?3,'2026-01-01T00:00:00.000Z')",params![purpose,nonce.as_slice(),ciphertext]).unwrap();
}
conn
}
// Exercise the actual persisted formats, including failed creates whose SSH
// snapshot remains retry authority. The encrypted fixture uses the real old AAD.
fn legacy_ssh_snapshot_times(conn: &Connection, key_created: &str, workdir_updated: &str) {
conn.execute(
"UPDATE repository_secret_operations SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE repository_secret_audit_events SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE repository_ssh_credentials SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute(
"UPDATE server_secret_versions SET created_at=?1",
[key_created],
)
.unwrap();
conn.execute("INSERT INTO repository_secret_operations VALUES('space','create-host','intent','host_trust','host',3,?1)", [key_created]).unwrap();
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_created','host',3,'actor',?1)", [key_created]).unwrap();
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','host-key','host-fingerprint',3,?1,?1)", [key_created]).unwrap();
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'example.test',22,'ssh-ed25519','host-key','host-fingerprint',?1)", [key_created]).unwrap();
conn.execute("UPDATE workdir_create_operations SET state='failed',failure='runtime_workdir_create_failed',host_trust_id='host',host_trust_revision=3,repository_access_mode='read_only',updated_at=?1", [workdir_updated]).unwrap();
}
#[test]
fn repository_key_cutover_compares_mixed_timestamp_formats_as_instants() {
for (key_created, workdir_updated) in [
("2026-01-01T00:00:00.000Z", "1767225600000"),
("2026-01-01T00:00:00.000Z", "1767225600001"),
("2026-01-01T09:00:00.000+09:00", "2026-01-01T00:00:00Z"),
(
"2026-01-01T00:00:00.000000001Z",
"2026-01-01T00:00:00.000000002Z",
),
] {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
legacy_ssh_snapshot_times(&conn, key_created, workdir_updated);
migrate_repository_keys_v86_to_v87(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 87);
let snapshot: (String, String, String, String) = conn.query_row(
"SELECT state,credential_fingerprint,host_trust_fingerprint,updated_at FROM workdir_create_operations",
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)),
).unwrap();
assert_eq!(
snapshot,
(
"failed".into(),
"fingerprint".into(),
"host-fingerprint".into(),
workdir_updated.into()
)
);
let retained: (String, String, String) = conn.query_row(
"SELECT c.credential_fingerprint,r.credential_fingerprint,r.credential_operation_id
FROM workdir_create_credential_candidates c JOIN workdir_create_credential_retentions r
ON r.workspace_id=c.workspace_id AND r.operation_id=c.operation_id AND r.ordinal=c.ordinal",
[], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?)),
).unwrap();
assert_eq!(
retained,
(
"fingerprint".into(),
"fingerprint".into(),
"create-key".into()
)
);
assert_eq!(
conn.query_row(
"SELECT created_at FROM repository_ssh_credential_keys",
[],
|r| r.get::<_, String>(0)
)
.unwrap(),
key_created
);
assert!(
!conn
.prepare("PRAGMA foreign_key_check")
.unwrap()
.exists([])
.unwrap()
);
}
}
fn assert_timestamp_cutover_rolls_back(conn: &Connection, expected_context: &str) {
let before =
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap();
let ciphertext: Vec<u8> = conn
.query_row(
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
[],
|r| r.get(0),
)
.unwrap();
let error = migrate_repository_keys_v86_to_v87(conn)
.unwrap_err()
.to_string();
assert!(error.contains(expected_context), "{error}");
assert!(error.contains("timestamp"), "{error}");
assert_eq!(current_schema_version(conn).unwrap(), 86);
assert_eq!(
frozen_repository_json_rows(conn, "SELECT * FROM workdir_create_operations", &[]).unwrap(),
before
);
assert_eq!(
conn.query_row(
"SELECT ciphertext FROM server_secret_versions WHERE purpose='private_key'",
[],
|r| r.get::<_, Vec<u8>>(0)
)
.unwrap(),
ciphertext
);
assert!(table_exists(conn, "repository_ssh_credential_revisions").unwrap());
assert!(!table_exists(conn, "repository_ssh_credential_keys").unwrap());
assert_eq!(
conn.query_row(
"SELECT count(*) FROM workdir_create_credential_candidates",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
assert_eq!(
conn.query_row(
"SELECT count(*) FROM workdir_create_credential_revision_retentions",
[],
|r| r.get::<_, i64>(0)
)
.unwrap(),
1
);
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_temp_master WHERE name IN ('repository_key_cutover','repository_cutover_times','workdir_terminal_archive_cutover')", [], |r| r.get::<_,i64>(0)).unwrap(), 0);
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
.unwrap(),
1
);
assert_eq!(
conn.query_row("PRAGMA legacy_alter_table", [], |r| r.get::<_, i64>(0))
.unwrap(),
0
);
}
#[test]
fn repository_key_cutover_rejects_newer_reused_snapshot_keys_without_committing_authority() {
for source in ["credential", "host_trust", "candidate"] {
for later in ["2026-01-01T00:00:00.001Z", "2026-01-01T00:00:00.000000001Z"] {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
if source == "host_trust" {
conn.execute(
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
[later],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [later]).unwrap();
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='host_trust_created'", [later]).unwrap();
} else {
conn.execute(
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
[later],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [later]).unwrap();
conn.execute("UPDATE repository_secret_audit_events SET created_at=?1 WHERE kind='credential_created'", [later]).unwrap();
if source == "candidate" {
conn.execute("UPDATE workdir_create_operations SET credential_id=NULL,credential_revision=NULL", []).unwrap();
}
}
assert_timestamp_cutover_rolls_back(&conn, "Workdir SSH snapshot");
}
}
}
#[test]
fn repository_key_cutover_invalid_authority_timestamps_roll_back_with_source_context() {
for (source, invalid) in [
("workdir_create_operations.updated_at", "not-a-date"),
(
"workdir_create_operations.updated_at",
"9223372036854775807",
),
(
"repository_ssh_credential_revisions.created_at",
"not-a-date",
),
(
"repository_ssh_host_trust_revisions.created_at",
"not-a-date",
),
("repository_secret_operations.created_at", "not-a-date"),
] {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
legacy_ssh_snapshot_times(&conn, "2026-01-01T00:00:00.000Z", "1767225600000");
match source {
"workdir_create_operations.updated_at" => {
conn.execute(
"UPDATE workdir_create_operations SET updated_at=?1",
[invalid],
)
.unwrap();
}
"repository_ssh_credential_revisions.created_at" => {
conn.execute(
"UPDATE repository_ssh_credential_revisions SET created_at=?1",
[invalid],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='credential'", [invalid]).unwrap();
}
"repository_ssh_host_trust_revisions.created_at" => {
conn.execute(
"UPDATE repository_ssh_host_trust_revisions SET created_at=?1",
[invalid],
)
.unwrap();
conn.execute("UPDATE repository_secret_operations SET created_at=?1 WHERE resource_kind='host_trust'", [invalid]).unwrap();
}
"repository_secret_operations.created_at" => {
conn.execute("INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','gone',1,?1)", [invalid]).unwrap();
conn.execute("INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','gone',1,'actor',?1)", [invalid]).unwrap();
}
_ => unreachable!(),
}
assert_timestamp_cutover_rolls_back(&conn, source);
}
}
#[test]
fn repository_key_cutover_rejects_orphan_candidates_without_dropping_source_evidence() {
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
conn.execute_batch("PRAGMA foreign_keys=OFF;
INSERT INTO workdir_create_credential_candidates VALUES('space','missing-parent',0,'primary','key',7);
PRAGMA foreign_keys=ON;").unwrap();
let before = frozen_repository_json_rows(
&conn,
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
&[],
)
.unwrap();
let error = migrate_repository_keys_v86_to_v87(&conn).unwrap_err();
assert!(
error
.to_string()
.contains("credential candidates without a Workdir create operation"),
"{error}"
);
assert_eq!(current_schema_version(&conn).unwrap(), 86);
assert_eq!(
frozen_repository_json_rows(
&conn,
"SELECT * FROM workdir_create_credential_candidates ORDER BY operation_id,ordinal",
&[]
)
.unwrap(),
before
);
assert!(table_exists(&conn, "repository_ssh_credential_revisions").unwrap());
assert!(!table_exists(&conn, "repository_ssh_credential_keys").unwrap());
assert_eq!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, i64>(0))
.unwrap(),
1
);
}
#[test]
fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
let dir = tempfile::tempdir().unwrap();
@@ -150,12 +439,12 @@ fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
.unwrap();
assert!(
conn.execute(
"UPDATE workdir_create_operations SET updated_at='later'",
"UPDATE workdir_create_operations SET updated_at='2026-01-04T00:00:00.000Z'",
[]
)
.is_err()
);
assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','created','created')", []).is_err());
assert!(conn.execute("INSERT INTO workdir_create_operations(workspace_id,operation_id,request_fingerprint,repository_id,resolved_runtime_id,config_projection_digest,working_directory_id,state,created_at,updated_at) VALUES('space','blocked','intent','repo','runtime','projection','other-workdir','pending','2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z')", []).is_err());
assert!(
!conn
.prepare("PRAGMA foreign_key_check")
@@ -274,14 +563,14 @@ fn repository_key_cutover_preserves_host_endpoints_deleted_audit_and_nondefault_
let dir = tempfile::tempdir().unwrap();
let conn = legacy_repository_keys(&dir.path().join("server.db"));
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'created');
INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'updated');
INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'created','updated');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','created');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','updated');
INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','updated');
INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'deleted');
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','deleted');
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','host-create','intent','host_trust','host',3,'2026-01-01T00:00:00.000Z');
INSERT INTO repository_secret_operations VALUES('space','host-move','intent','host_trust','host',4,'2026-01-02T00:00:00.000Z');
INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',3,'old.example.test',22,'ssh-ed25519','host-key','host-fingerprint','2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'new.example.test',2222,'ssh-ed25519','host-key','host-fingerprint','2026-01-02T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','host-audit','host_trust_rotated','host',4,'host-actor','2026-01-02T00:00:00.000Z');
INSERT INTO repository_secret_operations VALUES('space','delete-gone','intent','credential','gone',5,'2026-01-03T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','gone',5,'delete-actor','2026-01-03T00:00:00.000Z');
UPDATE workdir_create_operations SET host_trust_id='host',host_trust_revision=3;").unwrap();
conn.execute_batch(&format!(
"PRAGMA foreign_keys={foreign_keys}; PRAGMA legacy_alter_table={legacy_alter};"
@@ -342,8 +631,8 @@ fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retr
let digest = "sha256:db15419814117c33c089a36c5348bda85d01a53fccd8d4d427685daf5983353b";
conn.execute("UPDATE workdir_create_operations SET state='succeeded',request_fingerprint=?1,selector='main',source_kind='local_path',source_uri='/tmp/repo',source_revision=9,source_fingerprint='source'",[digest]).unwrap();
conn.execute_batch("DELETE FROM workdir_create_credential_revision_retentions;
INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'deleted');
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','deleted');
INSERT INTO repository_secret_operations VALUES('space','delete-key','delete-intent','credential','key',7,'2026-01-03T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','delete-audit','credential_deleted','key',7,'deleting-actor','2026-01-03T00:00:00.000Z');
DELETE FROM repository_ssh_credentials;").unwrap();
let original =
frozen_repository_json_rows(&conn, "SELECT * FROM workdir_create_operations", &[])
@@ -666,7 +955,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"credential",
"key",
1,
"2026-01-01",
"2026-01-01T00:00:00.000Z",
"credential_created",
),
(
@@ -675,7 +964,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"credential",
"key",
2,
"2026-01-02",
"2026-01-02T00:00:00.000Z",
"credential_rotated",
),
(
@@ -684,7 +973,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"host_trust",
"host",
1,
"2026-01-01",
"2026-01-01T00:00:00.000Z",
"host_trust_created",
),
(
@@ -693,7 +982,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
"host_trust",
"host",
2,
"2026-01-02",
"2026-01-02T00:00:00.000Z",
"host_trust_rotated",
),
] {
@@ -708,11 +997,11 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
)
.unwrap();
}
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01','2026-01-02')",[&next_fp]).unwrap();
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",[&next_fp]).unwrap();
let encryption_key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
for (legacy_counter, fingerprint, private, timestamp) in [
(1u64, &old_fp, &old_private, "2026-01-01"),
(2, &next_fp, &next_private, "2026-01-02"),
(1u64, &old_fp, &old_private, "2026-01-01T00:00:00.000Z"),
(2, &next_fp, &next_private, "2026-01-02T00:00:00.000Z"),
] {
conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![legacy_counter,fingerprint,timestamp]).unwrap();
let nonce = [legacy_counter as u8; 12];
@@ -727,10 +1016,10 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
.unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![legacy_counter,nonce.as_slice(),ciphertext,timestamp]).unwrap();
}
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01','2026-01-02')",params![public,old_fp]).unwrap();
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01T00:00:00.000Z','2026-01-02T00:00:00.000Z')",params![public,old_fp]).unwrap();
for (legacy_counter, hostname, timestamp) in [
(1, "old.example.test", "2026-01-01"),
(2, "new.example.test", "2026-01-02"),
(1, "old.example.test", "2026-01-01T00:00:00.000Z"),
(2, "new.example.test", "2026-01-02T00:00:00.000Z"),
] {
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![legacy_counter,hostname,public,old_fp,timestamp]).unwrap();
}
@@ -884,8 +1173,8 @@ fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() {
// The old deleted ID has been recreated using the same ordinal. Its earlier
// delete receipt must remain history, not acquire proof from this new key.
conn.execute_batch("DELETE FROM workdir_create_operations;
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01');
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01');").unwrap();
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01T00:00:00.000Z');").unwrap();
migrate_repository_keys_v86_to_v87(&conn).unwrap();
// The service consumes current receipt columns; unrelated schemas are not this fixture.
rename_saved_secret_receipt_counters(&conn).unwrap();
@@ -966,8 +1255,8 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
}
"inconsistent_retention" => {
// A different valid retained key passes the ordinal-only candidate FK.
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'later');
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','later');
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','another-key','intent','credential','key',8,'2026-01-04T00:00:00.000Z');
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',8,'ssh-ed25519','other','2026-01-04T00:00:00.000Z');
UPDATE workdir_create_credential_revision_retentions SET credential_revision=8;").unwrap();
}
"missing_audit_receipt" => {
@@ -978,12 +1267,12 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
.unwrap();
}
"missing_host_receipt" => {
conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'created','created');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','created');").unwrap();
conn.execute_batch("INSERT INTO repository_ssh_host_trusts VALUES('space','host','example.test',22,'ssh-ed25519','key','host-fingerprint',4,'2026-01-01T00:00:00.000Z','2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',4,'example.test',22,'ssh-ed25519','key','host-fingerprint','2026-01-01T00:00:00.000Z');").unwrap();
}
"foreign_key_violation" => {
conn.execute_batch("PRAGMA foreign_keys=OFF;
INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'created');
INSERT INTO repository_secret_operations VALUES('missing-space','orphan','intent','credential','deleted',1,'2026-01-01T00:00:00.000Z');
PRAGMA foreign_keys=ON;").unwrap();
}
_ => {}
@@ -817,17 +817,12 @@ mod current_authority {
fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) {
let store = SqliteWorkspaceStore::in_memory().unwrap();
let workspace_key =
worker_runtime::auth::RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_key =
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-key").unwrap();
let workspace_fingerprint = normalize_runtime_public_key(&workspace_key.public_key)
.unwrap()
.1;
store.with_conn(|conn| {
conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('owner','user','owner','Owner','created','updated');
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');")?;
conn.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519',?1,?2,'material','active','created','provisioned','updated')", params![workspace_key.public_key,workspace_fingerprint])?;
INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('space','owner','Space','active','created','updated');
INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) VALUES('space','workspace-key','ed25519','public','sha256:workspace','material','active','created','provisioned','updated');")?;
Ok(())
}).unwrap();
let request = WorkspaceRuntimeBinding {
@@ -838,11 +833,7 @@ mod current_authority {
public_key: runtime_key.public_key,
public_key_fingerprint: String::new(),
binding_id: String::new(),
state: WorkspaceRuntimeBindingState::Configured,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("workspace-key".into()),
workspace_public_key_fingerprint: Some(workspace_fingerprint),
workspace_trust_id: Some("runtime-issued-trust".into()),
created_at: "created".into(),
updated_at: "updated".into(),
revoked_at: None,
@@ -853,36 +844,10 @@ mod current_authority {
(store, binding)
}
fn evidence(binding: &WorkspaceRuntimeBinding) -> WorkspaceRuntimeVerificationEvidence {
WorkspaceRuntimeVerificationEvidence {
workspace_id: binding.workspace_id.clone(),
runtime_id: binding.runtime_id.clone(),
binding_id: binding.binding_id.clone(),
workspace_key_id: binding.workspace_key_id.clone().unwrap(),
workspace_public_key_fingerprint: binding
.workspace_public_key_fingerprint
.clone()
.unwrap(),
workspace_trust_id: binding.workspace_trust_id.clone().unwrap(),
runtime_public_key_fingerprint: binding.public_key_fingerprint.clone(),
challenge_id: "challenge".into(),
state: "verified".into(),
last_outcome: "verified".into(),
verified_at: Some("verified-at".into()),
checked_at: "checked-at".into(),
}
}
#[test]
fn same_key_reactivation_fences_old_cas_and_verification_evidence() {
fn same_key_reactivation_fences_old_config_and_admin_mutations() {
let (store, binding) = binding_store();
let old_proof = evidence(&binding);
store
.record_workspace_runtime_verification_attempt(&old_proof)
.unwrap();
store
.complete_workspace_runtime_verification(&old_proof)
.unwrap();
assert!(store.workspace_runtime_binding_matches(&binding).unwrap());
let (_, revoked) = store
.revoke_workspace_runtime_binding_key(
"space",
@@ -893,16 +858,22 @@ mod current_authority {
)
.unwrap();
assert_ne!(binding.binding_id, revoked.binding_id);
let mut replacement = binding.clone();
replacement.state = WorkspaceRuntimeBindingState::Configured;
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
assert!(!store.workspace_runtime_binding_matches(&revoked).unwrap());
let (_, reactivated) = store
.put_workspace_runtime_binding_key(replacement, Some(&revoked.binding_id), "owner")
.put_workspace_runtime_binding_key(binding.clone(), Some(&revoked.binding_id), "owner")
.unwrap();
assert_eq!(
binding.public_key_fingerprint,
reactivated.public_key_fingerprint
);
assert_ne!(binding.binding_id, reactivated.binding_id);
assert!(
store
.workspace_runtime_binding_matches(&reactivated)
.unwrap()
);
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
assert!(matches!(
store.put_workspace_runtime_binding_key(
reactivated.clone(),
@@ -921,42 +892,27 @@ mod current_authority {
),
Err(Error::RuntimeBindingIdConflict { .. })
));
assert!(
store
.record_workspace_runtime_verification_attempt(&old_proof)
.is_err()
);
assert!(
store
.complete_workspace_runtime_verification(&old_proof)
.is_err()
);
assert!(
!store
.workspace_runtime_verification_matches(
&reactivated,
&old_proof.workspace_public_key_fingerprint,
&old_proof.workspace_trust_id
)
.unwrap()
);
}
#[test]
fn endpoint_aba_and_workspace_key_change_cannot_reuse_verification_authority() {
fn endpoint_aba_fences_old_config_without_copying_workspace_signing_identity() {
let (store, binding) = binding_store();
let proof = evidence(&binding);
store
.record_workspace_runtime_verification_attempt(&proof)
.unwrap();
store
.complete_workspace_runtime_verification(&proof)
let renamed = store
.update_workspace_runtime_binding_metadata(
"space",
"remote",
"Renamed",
&binding.base_url,
"renamed-at",
)
.unwrap();
assert_eq!(binding.binding_id, renamed.binding_id);
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
let moved = store
.update_workspace_runtime_binding_metadata(
"space",
"remote",
"Remote",
"Renamed",
"https://other.test",
"changed",
)
@@ -965,35 +921,166 @@ mod current_authority {
.update_workspace_runtime_binding_metadata(
"space",
"remote",
"Remote",
"Renamed",
"https://remote.test",
"changed-back",
)
.unwrap();
assert_ne!(moved.binding_id, binding.binding_id);
assert_ne!(returned.binding_id, binding.binding_id);
assert!(
store
.record_workspace_runtime_verification_attempt(&proof)
.is_err()
);
let current = evidence(&returned);
store
.record_workspace_runtime_verification_attempt(&current)
.unwrap();
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'",[])?; Ok(()) }).unwrap();
assert!(
store
.complete_workspace_runtime_verification(&current)
.is_err()
);
assert!(!store.workspace_runtime_binding_matches(&renamed).unwrap());
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'", [])?; Ok(()) }).unwrap();
assert_eq!(
store
.get_workspace_runtime_binding("space", "remote")
.unwrap(),
Some(returned.clone())
);
assert!(store.workspace_runtime_binding_matches(&returned).unwrap());
}
fn rows(conn: &Connection, sql: &str) -> Vec<Vec<rusqlite::types::Value>> {
let mut stmt = conn.prepare(sql).unwrap();
let count = stmt.column_count();
stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect())
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
}
fn schema89_bindings() -> Connection {
let conn = legacy_binding_database();
migrate_runtime_bindings_v87_to_v88(&conn).unwrap();
conn.execute(
"INSERT INTO __yoi_schema_migrations VALUES(89,'descriptive legacy evidence columns')",
[],
)
.unwrap();
conn.execute_batch("INSERT INTO workspace_runtime_bindings VALUES
('space','configured-trust','Configured','https://configured.test','key-c','fp-c','binding-c','configured','workspace_identity','workspace-key','sha256:workspace','trust-c','created-c','updated-c',NULL),
('space','verified','Verified','https://verified.test','key-v','fp-v','binding-v','verified','workspace_identity','workspace-key','sha256:workspace','trust-v','created-v','updated-v',NULL),
('space','revoked-null-trust','Revoked','https://revoked.test','key-r','fp-r','binding-r','revoked','workspace_identity','workspace-key',NULL,NULL,'created-r','updated-r','revoked-r'),
('space','legacy-verified','Legacy','https://legacy.test','key-l','fp-l','binding-l','verified','legacy_server_issuer',NULL,NULL,NULL,'created-l','updated-l',NULL),
('space','legacy-revoked','Legacy revoked','https://legacy-r.test','key-lr','fp-lr','binding-lr','revoked','legacy_server_issuer',NULL,NULL,NULL,'created-lr','updated-lr','revoked-lr');
INSERT INTO workspace_runtime_verifications VALUES
('space','verified','binding-v','workspace-key','sha256:workspace','trust-v','fp-v','challenge-v','verified','verified','verified-at','checked-v'),
('space','configured-trust','binding-c','workspace-key','sha256:workspace','trust-c','fp-c','challenge-c','pending','challenge_issued',NULL,'checked-c'),
('space','revoked-null-trust','binding-r','workspace-key','sha256:workspace','old-trust','fp-r','challenge-r','failed','verification_failed',NULL,'checked-r');").unwrap();
conn
}
#[test]
fn schema90_preserves_connections_and_archives_all_auth_metadata_without_reenrollment() {
let conn = schema89_bindings();
let connections = rows(
&conn,
"SELECT workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at,revoked_at FROM workspace_runtime_bindings ORDER BY runtime_id",
);
let metadata = rows(
&conn,
"SELECT workspace_id,runtime_id,binding_id,state,workspace_key_id,workspace_public_key_fingerprint,workspace_trust_id,updated_at FROM workspace_runtime_bindings ORDER BY runtime_id",
);
let proofs = rows(
&conn,
"SELECT * FROM workspace_runtime_verifications ORDER BY runtime_id",
);
let identities = rows(&conn, "SELECT * FROM workspace_signing_identities");
let audit = rows(&conn, "SELECT * FROM workspace_runtime_binding_audit");
let removal = rows(&conn, "SELECT * FROM runtime_removal_operations");
migrate_runtime_binding_connections_v89_to_v90(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 90);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id"
),
connections
);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_binding_verification_archive ORDER BY runtime_id"
),
metadata
);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_verification_archive ORDER BY runtime_id"
),
proofs
);
assert_eq!(
rows(&conn, "SELECT * FROM workspace_signing_identities"),
identities
);
assert_eq!(
rows(&conn, "SELECT * FROM workspace_runtime_binding_audit"),
audit
);
assert_eq!(
rows(&conn, "SELECT * FROM runtime_removal_operations"),
removal
);
verify_runtime_binding_connection_schema(&conn).unwrap();
assert!(!table_exists(&conn, "workspace_runtime_verifications").unwrap());
assert!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
.unwrap()
);
assert_eq!(
rows(&conn, "SELECT * FROM pragma_foreign_key_check"),
Vec::<Vec<rusqlite::types::Value>>::new()
);
conn.execute_batch("UPDATE workspace_runtime_bindings SET base_url='https://moved.test',binding_id='binding-new' WHERE runtime_id='verified';
UPDATE workspace_runtime_bindings SET revoked_at='admin-revoked' WHERE runtime_id='configured-trust';
DELETE FROM workspace_runtime_bindings WHERE runtime_id='legacy-revoked';").unwrap();
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_binding_verification_archive ORDER BY runtime_id"
),
metadata
);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_verification_archive ORDER BY runtime_id"
),
proofs
);
conn.execute(
"UPDATE runtime_removal_operations SET state='cleanup_pending'",
[],
)
.unwrap();
assert!(conn.execute("UPDATE workspace_runtime_bindings SET display_name='Blocked' WHERE runtime_id='remote'", []).unwrap_err().to_string().contains("runtime_removal_in_progress"));
}
#[test]
fn schema90_failed_rebuild_rolls_back_connections_archives_and_schema_marker() {
let conn = schema89_bindings();
// An incompatible preexisting archive is not permission to discard historical evidence.
conn.execute_batch("CREATE TABLE workspace_runtime_verification_archive(existing TEXT);")
.unwrap();
let before = rows(
&conn,
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id",
);
assert!(migrate_runtime_binding_connections_v89_to_v90(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 89);
assert_eq!(
rows(
&conn,
"SELECT * FROM workspace_runtime_bindings ORDER BY runtime_id"
),
before
);
assert!(!table_exists(&conn, "workspace_runtime_binding_verification_archive").unwrap());
assert!(table_exists(&conn, "workspace_runtime_verifications").unwrap());
assert!(
conn.query_row("PRAGMA foreign_keys", [], |r| r.get::<_, bool>(0))
.unwrap()
.state,
WorkspaceRuntimeBindingState::Configured
);
}
}
+27 -228
View File
@@ -2,9 +2,7 @@ use crate::Error;
use crate::resource_broker::BackendResourceBroker;
#[cfg(test)]
use crate::resource_broker::BackendResourceTarget;
use crate::store::{
ControlPlaneStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBindingState,
};
use crate::store::{ControlPlaneStore, WorkspaceRuntimeAuthenticationMode};
use crate::workspace_signing_identity::WorkspaceSigningIdentityService;
use chrono::Utc;
use protocol::Segment;
@@ -66,11 +64,7 @@ use worker_runtime::ssh_host_key_probe::{
SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SshHostKeyProbeRequest,
SshHostKeyProbeResponse,
};
use worker_runtime::workspace_issuer::{
WorkspaceCapabilityClaims, WorkspaceRuntimeVerificationAcknowledgement,
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
WorkspaceRuntimeVerificationResponse, workspace_request_body_digest,
};
use worker_runtime::workspace_issuer::{WorkspaceCapabilityClaims, workspace_request_body_digest};
pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
@@ -925,32 +919,6 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
})
}
fn activate_workspace_authorization(&self, _binding: crate::store::WorkspaceRuntimeBinding) {}
fn send_workspace_verification_challenge(
&self,
_challenge: &WorkspaceRuntimeVerificationChallenge,
_bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
Err(RuntimePingFailure::new(
RuntimePingFailureKind::Unsupported,
"runtime_workspace_verification_unsupported",
"Workspace Runtime verification is unavailable for this Runtime provider",
))
}
fn send_workspace_verification_acknowledgement(
&self,
_acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
_bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
Err(RuntimePingFailure::new(
RuntimePingFailureKind::Unsupported,
"runtime_workspace_verification_unsupported",
"Workspace Runtime verification is unavailable for this Runtime provider",
))
}
fn list_hosts(&self, limit: usize) -> RuntimeList<InternalHostSummary>;
fn list_workers(&self, limit: usize) -> RuntimeList<InternalWorkerSummary>;
@@ -2277,48 +2245,6 @@ impl RuntimeRegistry {
runtime.ping()
}
pub fn activate_workspace_authorization(
&self,
runtime_id: &str,
binding: crate::store::WorkspaceRuntimeBinding,
) -> Result<(), RuntimeRegistryError> {
self.runtime(runtime_id)?
.activate_workspace_authorization(binding);
Ok(())
}
pub fn send_workspace_verification_challenge(
&self,
runtime_id: &str,
challenge: &WorkspaceRuntimeVerificationChallenge,
bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
let runtime = self.runtime(runtime_id).map_err(|_| {
RuntimePingFailure::new(
RuntimePingFailureKind::Configuration,
"runtime_verification_registration_unavailable",
"Registered Runtime binding is unavailable",
)
})?;
runtime.send_workspace_verification_challenge(challenge, bearer_token)
}
pub fn send_workspace_verification_acknowledgement(
&self,
runtime_id: &str,
acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
let runtime = self.runtime(runtime_id).map_err(|_| {
RuntimePingFailure::new(
RuntimePingFailureKind::Configuration,
"runtime_verification_registration_unavailable",
"Registered Runtime binding is unavailable",
)
})?;
runtime.send_workspace_verification_acknowledgement(acknowledgement, bearer_token)
}
fn runtimes_snapshot(&self) -> Vec<Arc<dyn WorkspaceWorkerRuntime>> {
self.runtimes
.read()
@@ -3518,7 +3444,9 @@ pub struct WorkspaceRuntimeAuthorization {
store: Arc<dyn ControlPlaneStore>,
signing_identities: WorkspaceSigningIdentityService,
backend_url: String,
binding: Arc<RwLock<Option<crate::store::WorkspaceRuntimeBinding>>>,
// The connection selected for this client. Compare it with the current
// administrative configuration before sending, never with a past auth result.
binding: crate::store::WorkspaceRuntimeBinding,
}
impl std::fmt::Debug for WorkspaceRuntimeAuthorization {
@@ -3535,19 +3463,13 @@ impl WorkspaceRuntimeAuthorization {
store: Arc<dyn ControlPlaneStore>,
signing_identities: WorkspaceSigningIdentityService,
backend_url: impl Into<String>,
binding: Option<crate::store::WorkspaceRuntimeBinding>,
binding: crate::store::WorkspaceRuntimeBinding,
) -> Self {
Self {
store,
signing_identities,
backend_url: backend_url.into(),
binding: Arc::new(RwLock::new(binding)),
}
}
fn activate(&self, binding: crate::store::WorkspaceRuntimeBinding) {
if let Ok(mut current) = self.binding.write() {
*current = Some(binding);
binding,
}
}
@@ -3559,30 +3481,14 @@ impl WorkspaceRuntimeAuthorization {
worker_id: Option<&str>,
body: &[u8],
) -> Result<String, RuntimeDiagnostic> {
let binding = self
.binding
.read()
.map_err(|_| {
diagnostic(
"workspace_runtime_authorization_unavailable",
HostDiagnosticSeverity::Error,
"Workspace Runtime authorization is unavailable".to_string(),
)
})?
.clone()
.ok_or_else(|| {
diagnostic(
"workspace_runtime_verification_required",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is not verified".to_string(),
)
})?;
if binding.state != WorkspaceRuntimeBindingState::Verified
|| binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
let binding = &self.binding;
// A removed/replaced endpoint must not be used by an old client. This
// compares connection configuration, not cached authentication success.
if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
|| binding.revoked_at.is_some()
|| !self
.store
.workspace_runtime_binding_matches(&binding)
.workspace_runtime_binding_matches(binding)
.map_err(|error| {
diagnostic(
"workspace_runtime_authorization_unavailable",
@@ -3594,7 +3500,7 @@ impl WorkspaceRuntimeAuthorization {
return Err(diagnostic(
"workspace_runtime_authorization_stale",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding changed or was revoked".to_string(),
"Workspace Runtime connection changed or was revoked".to_string(),
));
}
let identity = self
@@ -3607,63 +3513,26 @@ impl WorkspaceRuntimeAuthorization {
error.to_string(),
)
})?;
let workspace_key_id = binding.workspace_key_id.as_deref().ok_or_else(|| {
diagnostic(
"workspace_runtime_authorization_invalid",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is missing its Workspace key".to_string(),
)
})?;
let workspace_public_key_fingerprint = binding
.workspace_public_key_fingerprint
.as_deref()
.ok_or_else(|| {
diagnostic(
"workspace_runtime_authorization_invalid",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is missing its Workspace public key fingerprint"
.to_string(),
)
})?;
let workspace_trust_id = binding.workspace_trust_id.as_deref().ok_or_else(|| {
diagnostic(
"workspace_runtime_authorization_invalid",
HostDiagnosticSeverity::Error,
"Workspace Runtime binding is missing its Workspace trust ID".to_string(),
)
})?;
if identity.state != "active"
|| identity.key_id != workspace_key_id
|| identity.public_key_fingerprint.as_deref() != Some(workspace_public_key_fingerprint)
|| !self
.store
.workspace_runtime_verification_matches(
&binding,
workspace_public_key_fingerprint,
workspace_trust_id,
)
.map_err(|error| {
diagnostic(
"workspace_runtime_authorization_unavailable",
HostDiagnosticSeverity::Error,
error.to_string(),
)
})?
{
if identity.state != "active" {
return Err(diagnostic(
"workspace_runtime_authorization_stale",
"workspace_signing_identity_unavailable",
HostDiagnosticSeverity::Error,
"Workspace signing identity no longer matches the verified binding".to_string(),
"Workspace signing identity is not active".to_string(),
));
}
let fingerprint = identity.public_key_fingerprint.ok_or_else(|| {
diagnostic(
"workspace_signing_identity_unavailable",
HostDiagnosticSeverity::Error,
"Workspace signing identity has no public key fingerprint".to_string(),
)
})?;
let now = Utc::now().timestamp();
let claims = WorkspaceCapabilityClaims {
issuer: self.backend_url.clone(),
issuer_workspace_id: binding.workspace_id.clone(),
issuer_key_id: identity.key_id,
issuer_public_key_fingerprint: workspace_public_key_fingerprint.to_string(),
trust_id: workspace_trust_id.to_string(),
binding_id: binding.binding_id.clone(),
issuer_public_key_fingerprint: fingerprint,
runtime_id: binding.runtime_id.clone(),
worker_id: worker_id.map(str::to_string),
operation: operation.to_string(),
@@ -3674,6 +3543,8 @@ impl WorkspaceRuntimeAuthorization {
exp: now.saturating_add(60),
jti: uuid::Uuid::now_v7().to_string(),
};
// The signer validates that these claims still name the selected current
// identity; a concurrent rotation cannot silently sign mismatched claims.
self.signing_identities
.issue_workspace_capability(&binding.workspace_id, &claims)
.map_err(|error| {
@@ -4251,49 +4122,6 @@ impl RemoteWorkerRuntime {
format!("{base}/v1/workers/{worker_id}/protocol/ws")
}
fn post_bearer_json<T, U>(
&self,
path: &str,
body: &T,
bearer_token: &str,
) -> Result<U, RuntimePingFailure>
where
T: Serialize + ?Sized,
U: DeserializeOwned,
{
let response = self
.http
.post(self.endpoint(path))
.bearer_auth(bearer_token)
.json(body)
.send()
.map_err(|error| {
RuntimePingFailure::new(
RuntimePingFailureKind::NetworkUnreachable,
"runtime_workspace_verification_unreachable",
format!("Runtime verification request failed: {error}"),
)
})?;
let status = response.status();
if !status.is_success() {
return Err(RuntimePingFailure::new(
RuntimePingFailureKind::MalformedResponse,
"runtime_workspace_verification_rejected",
format!(
"Runtime verification request returned HTTP {}",
status.as_u16()
),
));
}
response.json::<U>().map_err(|error| {
RuntimePingFailure::new(
RuntimePingFailureKind::MalformedResponse,
"runtime_workspace_verification_invalid_response",
format!("Runtime verification response was invalid: {error}"),
)
})
}
fn get_json<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
where
T: DeserializeOwned + Send + 'static,
@@ -4702,36 +4530,6 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
})
}
fn activate_workspace_authorization(&self, binding: crate::store::WorkspaceRuntimeBinding) {
if let Some(authorization) = &self.workspace_authorization {
authorization.activate(binding);
}
}
fn send_workspace_verification_challenge(
&self,
challenge: &WorkspaceRuntimeVerificationChallenge,
bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationResponse, RuntimePingFailure> {
self.post_bearer_json(
worker_runtime::workspace_issuer::WORKSPACE_VERIFICATION_CHALLENGE_PATH,
challenge,
bearer_token,
)
}
fn send_workspace_verification_acknowledgement(
&self,
acknowledgement: &WorkspaceRuntimeVerificationAcknowledgement,
bearer_token: &str,
) -> Result<WorkspaceRuntimeVerificationReceipt, RuntimePingFailure> {
self.post_bearer_json(
worker_runtime::workspace_issuer::WORKSPACE_VERIFICATION_ACK_PATH,
acknowledgement,
bearer_token,
)
}
fn list_hosts(&self, limit: usize) -> RuntimeList<InternalHostSummary> {
if limit == 0 {
return RuntimeList::new(Vec::new(), Vec::new());
@@ -6536,6 +6334,7 @@ pub fn placeholder_spawn_response(host_id: impl Into<String>) -> WorkerSpawnResu
#[cfg(test)]
mod tests {
mod restore_auth_tests;
mod workspace_auth_tests;
use super::*;
use serde_json::json;
@@ -9,36 +9,18 @@ use worker_runtime::{
auth::RuntimeIdentityMaterial,
http_server::{WorkspaceRuntimeHttpAuth, runtime_http_router_with_workspace_auth},
workspace_issuer::{
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
RuntimeVerificationSigner, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
WorkspaceIssuerTrustState, WorkspaceRuntimeVerificationAuthority,
WorkspaceRuntimeVerificationRecord, issue_workspace_capability_token,
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, issue_workspace_capability_token,
},
};
#[tokio::test]
async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_binding() {
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
let now = Utc::now().timestamp();
let workspace_public_key_fingerprint =
crate::workspace_signing_identity::public_key_fingerprint(&workspace_identity.public_key)
.unwrap();
let binding_id = "binding-restore-test".to_string();
let verifications = Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default());
verifications
.record(WorkspaceRuntimeVerificationRecord {
workspace_id: "workspace-test".into(),
runtime_id: "runtime-test".into(),
binding_id: binding_id.clone(),
workspace_trust_id: "trust-restore-test".to_string(),
workspace_key_id: "workspace-key".into(),
workspace_public_key_fingerprint: workspace_public_key_fingerprint.clone(),
runtime_public_key_fingerprint: signer.public_key_fingerprint().to_owned(),
verified_at: now,
})
.unwrap();
let public_key =
worker_runtime::auth::decode_public_key(&workspace_identity.public_key).unwrap();
let verifier = WorkspaceCapabilityVerifier::new(
@@ -69,8 +51,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
None,
WorkspaceRuntimeHttpAuth {
verifier,
signer,
verifications,
runtime_id: "runtime-test".into(),
},
);
let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
@@ -93,8 +74,6 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
"wrong-worker",
"wrong-body",
"wrong-key-fingerprint",
"wrong-binding-id",
"wrong-trust-id",
] {
let claims = WorkspaceCapabilityClaims {
issuer: "https://backend.test".into(),
@@ -111,16 +90,6 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
} else {
workspace_public_key_fingerprint.clone()
},
binding_id: if case == "wrong-binding-id" {
"binding-retired".to_string()
} else {
binding_id.clone()
},
trust_id: if case == "wrong-trust-id" {
"trust-retired".to_string()
} else {
"trust-restore-test".to_string()
},
runtime_id: "runtime-test".into(),
worker_id: if case == "wrong-worker" {
Some(EmbeddedWorkerId::from_legacy_u64(2).to_string())
@@ -176,27 +145,16 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
} else {
assert_eq!(
status,
if case == "wrong-binding-id" {
StatusCode::FORBIDDEN
} else {
StatusCode::UNAUTHORIZED
},
StatusCode::UNAUTHORIZED,
"{path} {case}: {}",
error.error.message
);
assert_eq!(
error.error.code,
if case == "wrong-binding-id" {
"workspace_runtime_verification_stale"
} else {
"unauthorized"
}
);
assert_eq!(error.error.code, "unauthorized");
let expected = match case {
"wrong-operation" => Some("does not authorize this operation"),
"wrong-worker" => Some("targets another Worker"),
"wrong-body" => Some("does not bind this request body"),
"wrong-key-fingerprint" | "wrong-binding-id" | "wrong-trust-id" => None,
"wrong-key-fingerprint" => None,
_ => unreachable!(),
};
if let Some(expected) = expected {
@@ -0,0 +1,316 @@
use super::*;
use crate::store::{
AccountRecord, SqliteWorkspaceStore, WorkspaceRecord, WorkspaceRuntimeBinding,
WorkspaceSigningIdentityRecord,
};
use crate::workspace_signing_identity::{
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
WorkspaceSigningPrivateMaterial, public_key_fingerprint,
};
use worker_runtime::auth::RuntimeIdentityMaterial;
use worker_runtime::workspace_issuer::{
InMemoryWorkspaceClaimReplayProtection, VerifiedWorkspaceCapability,
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerificationError,
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
};
const WORKSPACE: &str = "workspace-auth-test";
const RUNTIME: &str = "runtime-auth-test";
const BACKEND: &str = "https://backend.test";
const PATH: &str = "/v1/ping?probe=1";
struct Fixture {
_temp: tempfile::TempDir,
store: Arc<SqliteWorkspaceStore>,
materials: Arc<InMemoryWorkspaceSigningMaterialStore>,
service: WorkspaceSigningIdentityService,
authorizer: WorkspaceRuntimeAuthorization,
binding: WorkspaceRuntimeBinding,
identity: WorkspaceSigningIdentityRecord,
}
impl Fixture {
async fn new() -> Self {
let temp = tempfile::tempdir().unwrap();
let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap());
store
.upsert_account(&AccountRecord {
account_id: "owner".into(),
kind: "user".into(),
handle: "owner".into(),
display_name: "Owner".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: WORKSPACE.into(),
owner_account_id: "owner".into(),
display_name: "Workspace".into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
})
.await
.unwrap();
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let identity = service.provision_existing(WORKSPACE, "owner").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate(RUNTIME).unwrap();
store
.upsert_workspace_runtime_binding(
WorkspaceRuntimeBinding {
workspace_id: WORKSPACE.into(),
runtime_id: RUNTIME.into(),
display_name: "Runtime".into(),
base_url: "https://runtime.test".into(),
public_key_fingerprint: public_key_fingerprint(&runtime_identity.public_key)
.unwrap(),
public_key: runtime_identity.public_key,
binding_id: String::new(),
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
created_at: "1".into(),
updated_at: "1".into(),
revoked_at: None,
},
false,
)
.unwrap();
let binding = store
.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
.unwrap()
.unwrap();
let authorizer = WorkspaceRuntimeAuthorization::new(
store.clone(),
service.clone(),
BACKEND,
binding.clone(),
);
Self {
_temp: temp,
store,
materials,
service,
authorizer,
binding,
identity,
}
}
fn issue(&self) -> Result<String, RuntimeDiagnostic> {
self.authorizer
.issue("GET", PATH, RUNTIME_PING_PERMISSION, None, b"")
}
fn verifier(&self) -> WorkspaceCapabilityVerifier {
WorkspaceCapabilityVerifier::new(
vec![WorkspaceIssuerTrustRecord {
workspace_id: WORKSPACE.into(),
backend_url: BACKEND.into(),
key_id: self.identity.key_id.clone(),
algorithm: "ed25519".into(),
public_key: self.identity.public_key.clone().unwrap(),
public_key_fingerprint: self.identity.public_key_fingerprint.clone().unwrap(),
trust_id: uuid::Uuid::now_v7().to_string(),
state: WorkspaceIssuerTrustState::Active,
registered_at_unix: Utc::now().timestamp(),
updated_at_unix: Utc::now().timestamp(),
}],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap()
}
// Fixture-only rotation: deliberately no production rotation API or live database.
fn rotate_identity(&mut self) {
let key_id = "WK-rotated";
let material_ref = format!("workspace-signing/{WORKSPACE}/{key_id}");
let material = WorkspaceSigningPrivateMaterial::generate(WORKSPACE, key_id).unwrap();
let public_key = material.validate_and_public_key(WORKSPACE, key_id).unwrap();
let fingerprint = public_key_fingerprint(&public_key).unwrap();
self.materials
.put_if_absent(&material_ref, &material)
.unwrap();
self.store.with_conn(|conn| {
conn.execute("UPDATE workspace_signing_identities SET key_id = ?1, public_key = ?2, public_key_fingerprint = ?3, private_material_ref = ?4 WHERE workspace_id = ?5",
rusqlite::params![key_id, public_key, fingerprint, material_ref, WORKSPACE])?;
Ok(())
}).unwrap();
self.identity = self.service.get_validated(WORKSPACE).unwrap();
}
}
fn verify(
verifier: &WorkspaceCapabilityVerifier,
token: &str,
) -> Result<VerifiedWorkspaceCapability, WorkspaceCapabilityVerificationError> {
verifier.verify(
token,
&WorkspaceCapabilityExpectation {
workspace_id: WORKSPACE,
runtime_id: RUNTIME,
worker_id: None,
operation: RUNTIME_PING_PERMISSION,
method: "GET",
path_and_query: PATH,
body_digest: &workspace_request_body_digest(b""),
now_unix: Utc::now().timestamp(),
},
)
}
#[tokio::test]
async fn authorizer_token_authenticates_actual_request_without_prior_verification_record() {
let fixture = Fixture::new().await;
let token = fixture.issue().unwrap();
let verifier = fixture.verifier();
let authenticated = verify(&verifier, &token).unwrap();
assert_eq!(authenticated.workspace_id, WORKSPACE);
assert_eq!(authenticated.issuer_key_id, fixture.identity.key_id);
assert_eq!(
verify(&verifier, &token).unwrap_err(),
WorkspaceCapabilityVerificationError::Replay
);
let token = fixture.issue().unwrap();
let wrong_request = WorkspaceCapabilityExpectation {
workspace_id: WORKSPACE,
runtime_id: RUNTIME,
worker_id: None,
operation: RUNTIME_PING_PERMISSION,
method: "GET",
path_and_query: "/v1/ping?probe=2",
body_digest: &workspace_request_body_digest(b""),
now_unix: Utc::now().timestamp(),
};
assert_eq!(
verifier.verify(&token, &wrong_request).unwrap_err(),
WorkspaceCapabilityVerificationError::WrongPathAndQuery
);
}
#[tokio::test]
async fn existing_authorizer_selects_rotated_workspace_key_and_new_runtime_trust() {
let mut fixture = Fixture::new().await;
let old_verifier = fixture.verifier();
let old_token = fixture.issue().unwrap();
fixture.rotate_identity();
let new_token = fixture.issue().unwrap();
let new_verifier = fixture.verifier();
assert_eq!(
verify(&new_verifier, &new_token).unwrap().issuer_key_id,
"WK-rotated"
);
assert_eq!(
verify(&new_verifier, &old_token).unwrap_err(),
WorkspaceCapabilityVerificationError::WrongIssuerKey
);
assert_eq!(
verify(&old_verifier, &new_token).unwrap_err(),
WorkspaceCapabilityVerificationError::WrongIssuerKey
);
// Changing Workspace authority did not replace or copy metadata into the connection.
assert_eq!(
fixture
.store
.get_workspace_runtime_binding(WORKSPACE, RUNTIME)
.unwrap()
.unwrap(),
fixture.binding
);
}
#[tokio::test]
async fn replaced_or_revoked_connection_rejects_stale_authorizer() {
for revoked in [false, true] {
let fixture = Fixture::new().await;
fixture.issue().unwrap();
if revoked {
fixture
.store
.revoke_workspace_runtime_binding(WORKSPACE, RUNTIME, "2")
.unwrap();
} else {
let mut replacement = fixture.binding.clone();
replacement.base_url = "https://replacement-runtime.test".into();
fixture
.store
.upsert_workspace_runtime_binding(replacement, true)
.unwrap();
}
assert_eq!(
fixture.issue().unwrap_err().code,
"workspace_runtime_authorization_stale"
);
}
}
#[tokio::test]
async fn missing_workspace_signing_material_rejects_authorization_without_fallback() {
let fixture = Fixture::new().await;
fixture
.materials
.delete(&fixture.identity.private_material_ref)
.unwrap();
let error = fixture.issue().unwrap_err();
assert_eq!(error.code, "workspace_runtime_authorization_unavailable");
assert!(
error.message.contains("private material is missing"),
"{}",
error.message
);
}
#[tokio::test]
async fn capability_claims_must_match_current_workspace_identity_before_signing() {
let mut fixture = Fixture::new().await;
let now = Utc::now().timestamp();
let mut claims = WorkspaceCapabilityClaims {
issuer: BACKEND.into(),
issuer_workspace_id: WORKSPACE.into(),
issuer_key_id: fixture.identity.key_id.clone(),
issuer_public_key_fingerprint: fixture.identity.public_key_fingerprint.clone().unwrap(),
runtime_id: RUNTIME.into(),
worker_id: None,
operation: RUNTIME_PING_PERMISSION.into(),
method: "GET".into(),
path_and_query: PATH.into(),
body_digest: workspace_request_body_digest(b""),
iat: now,
exp: now + 60,
jti: uuid::Uuid::now_v7().to_string(),
};
let token = fixture
.service
.issue_workspace_capability(WORKSPACE, &claims)
.unwrap();
verify(&fixture.verifier(), &token).unwrap();
for field in ["workspace", "key", "fingerprint"] {
let mut mismatched = claims.clone();
match field {
"workspace" => mismatched.issuer_workspace_id = "another-workspace".into(),
"key" => mismatched.issuer_key_id = "WK-another".into(),
"fingerprint" => {
mismatched.issuer_public_key_fingerprint = format!("sha256:{}", "0".repeat(64))
}
_ => unreachable!(),
}
assert!(
matches!(fixture.service.issue_workspace_capability(WORKSPACE, &mismatched),
Err(Error::WorkspaceSigningIdentity { ref code, .. }) if code == "workspace_capability_identity_mismatch"),
"{field}"
);
}
fixture.rotate_identity();
assert!(
matches!(fixture.service.issue_workspace_capability(WORKSPACE, &claims),
Err(Error::WorkspaceSigningIdentity { ref code, .. }) if code == "workspace_capability_identity_mismatch")
);
claims.issuer_key_id = fixture.identity.key_id.clone();
claims.issuer_public_key_fingerprint = fixture.identity.public_key_fingerprint.clone().unwrap();
let token = fixture
.service
.issue_workspace_capability(WORKSPACE, &claims)
.unwrap();
verify(&fixture.verifier(), &token).unwrap();
}
+21 -27
View File
@@ -442,49 +442,43 @@ CREATE TABLE workspace_runtime_bindings (
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
workspace_key_id TEXT,
workspace_public_key_fingerprint TEXT,
workspace_trust_id TEXT CHECK (length(workspace_trust_id) > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revoked_at TEXT,
PRIMARY KEY (workspace_id, runtime_id),
UNIQUE (workspace_id, public_key_fingerprint),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
CHECK (
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_public_key_fingerprint IS NULL AND workspace_trust_id IS NULL)
OR
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND (state != 'verified' OR (workspace_trust_id IS NOT NULL AND workspace_public_key_fingerprint IS NOT NULL)))
),
CHECK (
(state = 'revoked' AND revoked_at IS NOT NULL)
OR
(state != 'revoked' AND revoked_at IS NULL)
)
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
);
CREATE TABLE workspace_runtime_verifications (
-- Historical authentication metadata only, never current admission authority.
CREATE TABLE workspace_runtime_binding_verification_archive (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
binding_id TEXT NOT NULL CHECK(length(binding_id) > 0),
binding_id TEXT NOT NULL,
state TEXT NOT NULL,
workspace_key_id TEXT,
workspace_public_key_fingerprint TEXT,
workspace_trust_id TEXT,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, runtime_id, binding_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE workspace_runtime_verification_archive (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
binding_id TEXT NOT NULL,
workspace_key_id TEXT NOT NULL,
workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0),
workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0),
workspace_public_key_fingerprint TEXT NOT NULL,
workspace_trust_id TEXT NOT NULL,
runtime_public_key_fingerprint TEXT NOT NULL,
challenge_id TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
state TEXT NOT NULL,
last_outcome TEXT NOT NULL,
verified_at TEXT,
checked_at TEXT NOT NULL,
PRIMARY KEY(workspace_id, runtime_id),
FOREIGN KEY(workspace_id, runtime_id)
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE,
CHECK((state = 'verified' AND verified_at IS NOT NULL)
OR (state != 'verified' AND verified_at IS NULL))
PRIMARY KEY (workspace_id, runtime_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE INDEX workspace_runtime_verifications_state_idx
ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);
CREATE TABLE workspace_runtime_binding_audit (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
@@ -48,7 +48,7 @@ fn evidence_column_cutover_preserves_all_values_and_current_schema_names() {
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
upgraded
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 89);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(evidence_rows(conn, "artifacts"), artifacts);
assert_eq!(
evidence_rows(conn, "repository_secret_legacy_receipts"),
+2 -10
View File
@@ -10,7 +10,7 @@ use yoi_workspace_server::hosts::{
};
use yoi_workspace_server::store::{
AccountRecord, ApiTokenRecord, SqliteWorkspaceStore, UserRecord,
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingState,
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
};
use yoi_workspace_server::{
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
@@ -456,7 +456,7 @@ fn append_workspace_runtime_sources(
.into_iter()
.filter(|binding| {
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
&& binding.state == WorkspaceRuntimeBindingState::Verified
&& binding.revoked_at.is_none()
})
.collect::<Vec<_>>()
})
@@ -787,11 +787,7 @@ mod tests {
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_id: "binding-test".to_owned(),
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_public_key_fingerprint: None,
workspace_trust_id: None,
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
@@ -815,11 +811,7 @@ mod tests {
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_id: "binding-test".to_owned(),
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-test".to_owned()),
workspace_public_key_fingerprint: Some("sha256:workspace-key".to_owned()),
workspace_trust_id: Some("trust-test".to_owned()),
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
@@ -80,10 +80,10 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
seed_migration_workspace(&conn);
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'created');
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','created',NULL);
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','created');
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','created');").unwrap();
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'2026-01-01T00:00:00.000Z');
INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519','fingerprint',7,'active','2026-01-01T00:00:00.000Z',NULL);
INSERT INTO repository_ssh_credential_revisions VALUES('space','key',7,'ssh-ed25519','fingerprint','2026-01-01T00:00:00.000Z');
INSERT INTO repository_secret_audit_events VALUES('space','audit','credential_created','key',7,'owner','2026-01-01T00:00:00.000Z');").unwrap();
let key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
let mut ciphertext = b"private bytes".to_vec();
key.seal_in_place_append_tag(
@@ -92,7 +92,7 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
&mut ciphertext,
)
.unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'created')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',7,'private_key','aes-256-gcm-v1',?1,?2,'2026-01-01T00:00:00.000Z')",params![[1u8;12].as_slice(),ciphertext]).unwrap();
drop(conn);
let master_key = dir.path().join("repository-secrets.master-key");
std::fs::write(&master_key, [42; 32]).unwrap();
@@ -56,7 +56,8 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
"flow_source_contents",
"repositories",
"workspace_runtime_bindings",
"workspace_runtime_verifications",
"workspace_runtime_binding_verification_archive",
"workspace_runtime_verification_archive",
"workspace_runtime_binding_audit",
"workspace_signing_identities",
"workspace_signing_identity_provisioning_operations",
File diff suppressed because it is too large Load Diff
@@ -111,11 +111,9 @@ impl RealDriveFixture {
public_key: identity.public_key.clone(),
public_key_fingerprint: String::new(),
binding_id: "binding-drive-test".to_string(),
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_public_key_fingerprint: None,
workspace_trust_id: None,
created_at: "1".into(),
updated_at: "1".into(),
revoked_at: None,
@@ -160,12 +160,10 @@ impl LaunchFixture {
if remote {
fixture.runtime_id = "ticket-worker-real-http".to_string();
use worker_runtime::workspace_issuer::{
InMemoryWorkspaceClaimReplayProtection,
InMemoryWorkspaceRuntimeVerificationAuthority, RuntimeVerificationSigner,
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
};
let runtime_identity = RuntimeIdentityMaterial::generate(&fixture.runtime_id).unwrap();
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
let workspace_identity = fixture
.api
.signing_identities
@@ -204,13 +202,11 @@ impl LaunchFixture {
display_name: "Ticket Worker real HTTP Runtime".into(),
base_url: endpoint.clone(),
public_key: runtime_identity.public_key.clone(),
public_key_fingerprint: signer.public_key_fingerprint().to_string(),
public_key_fingerprint: String::new(),
binding_id: "binding-launch-test".to_string(),
state: StoredRuntimeBindingState::Configured,
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some(workspace_identity.key_id),
workspace_public_key_fingerprint: workspace_identity.public_key_fingerprint.clone(),
workspace_trust_id: Some("trust-launch-test".to_string()),
created_at: TEST_CREATED_AT.into(),
updated_at: TEST_CREATED_AT.into(),
revoked_at: None,
@@ -228,6 +224,7 @@ impl LaunchFixture {
.await
.unwrap()
.unwrap();
let runtime_id = fixture.runtime_id.clone();
fixture.runtime_server = Some(tokio::spawn(async move {
worker_runtime::http_server::serve_runtime_http_with_workspace_auth(
runtime,
@@ -235,10 +232,7 @@ impl LaunchFixture {
None,
worker_runtime::http_server::WorkspaceRuntimeHttpAuth {
verifier,
signer,
verifications: Arc::new(
InMemoryWorkspaceRuntimeVerificationAuthority::default(),
),
runtime_id,
},
)
.await
@@ -258,27 +252,12 @@ impl LaunchFixture {
cached_status: "active".into(),
timeout: std::time::Duration::from_secs(5),
};
fixture.api.runtime.register_or_replace(
RemoteWorkerRuntime::new(
remote_config.clone(),
TEST_WORKSPACE_ID.to_string(),
backend_url.clone(),
)
.unwrap(),
);
let verified = perform_workspace_runtime_verification(
&fixture.api,
fixture.api.runtime.clone(),
&binding,
)
.await
.unwrap();
remote_config.workspace_authorization =
Some(crate::hosts::WorkspaceRuntimeAuthorization::new(
fixture.api.store.clone(),
fixture.api.signing_identities.clone(),
backend_url.clone(),
Some(verified),
binding,
));
fixture.api.runtime.register_or_replace(
RemoteWorkerRuntime::new(remote_config, TEST_WORKSPACE_ID.to_string(), backend_url)
@@ -694,7 +694,6 @@ async fn revoked_runtime_and_persisted_workspace_fence_reject_new_requests() {
.unwrap()
.unwrap();
binding.revoked_at = Some("revoked".into());
binding.state = StoredRuntimeBindingState::Revoked;
f.api
.store
.upsert_workspace_runtime_binding_record(binding, true)
@@ -1,5 +1,565 @@
use super::*;
// Exercise the generated tag routes behind real Runtime authentication. Each
// operation retry gets a fresh proof; an HTTP proof itself is single-use.
async fn runtime_tag_response(
app: Router,
request: Request<Body>,
expected: StatusCode,
) -> serde_json::Value {
let response = app.oneshot(request).await.unwrap();
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(status, expected, "{}", String::from_utf8_lossy(&body));
if body.is_empty() {
serde_json::Value::Null
} else {
serde_json::from_slice(&body).unwrap_or_else(|error| {
panic!(
"invalid JSON response: {error}: {}",
String::from_utf8_lossy(&body)
)
})
}
}
async fn runtime_tag_json(
app: Router,
identity: &RuntimeIdentityMaterial,
worker_id: &str,
method: &str,
path: &str,
input: serde_json::Value,
expected: StatusCode,
) -> serde_json::Value {
runtime_tag_response(
app,
runtime_source_request(
identity,
Some(worker_id),
method,
path,
serde_json::to_vec(&input).unwrap(),
),
expected,
)
.await
}
fn tag_execution_authority_snapshot(api: &WorkspaceApi) -> Vec<Vec<Vec<rusqlite::types::Value>>> {
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
[
"worker_registry",
"worker_control_grants",
"ticket_current_worker_assignments",
"ticket_assignment_operations",
"backend_jobs",
]
.into_iter()
.map(|table| {
let mut statement = conn
.prepare(&format!(
"SELECT * FROM {table} WHERE workspace_id=?1 ORDER BY rowid"
))
.unwrap();
let columns = statement.column_count();
statement
.query_map([TEST_WORKSPACE_ID], |row| {
(0..columns)
.map(|column| row.get(column))
.collect::<rusqlite::Result<Vec<_>>>()
})
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
})
.collect()
}
#[tokio::test]
async fn runtime_tag_mutations_replay_original_receipts_and_preserve_execution_authority() {
let dir = tempfile::tempdir().unwrap();
let (mut api, execution) = test_api_with_recording_backend(dir.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-tags");
let source = RuntimeWorkerRef::new("runtime-tags", "classifier");
let recipient = RuntimeWorkerRef::new("runtime-tags", "recipient");
seed_worker_source_member(&api, &source.runtime_id, &source.worker_id);
seed_worker_source_member(&api, &recipient.runtime_id, &recipient.worker_id);
seed_worker_control_grant(&api, &source, &recipient, "existing-control");
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend
.create(ticket::NewTicket::new("Classification is not a decision"))
.unwrap();
api.store
.set_current_ticket_role_assignment(
&TicketRoleAssignmentRecord {
workspace_id: TEST_WORKSPACE_ID.into(),
ticket_id: reference.id.clone(),
assignment_id: "existing-assignment".into(),
role: TicketAssignmentRole::Worker,
principal: TicketAssignmentPrincipal::Worker {
runtime_id: recipient.runtime_id.clone(),
worker_id: recipient.worker_id.clone(),
},
assigned_by: "test".into(),
assigned_at: TEST_CREATED_AT.into(),
},
None,
"existing-assignment-event",
"existing-assignment-operation",
false,
)
.unwrap();
let authority_before = tag_execution_authority_snapshot(&api);
let ticket_before = backend.show(reference.id.clone().into()).unwrap();
let app = build_router(api.clone());
let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
let membership_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/tags", reference.id);
let create =
json!({"operation_key":"runtime-create", "action":{"kind":"create", "name":" ready "}});
let tag = runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"POST",
&tags_path,
create.clone(),
StatusCode::OK,
)
.await;
assert_eq!(tag["name"], "ready");
assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID);
let attach =
json!({"operation_key":"runtime-attach", "expected_tag_ids":[], "tag_ids":[tag["id"]]});
let attached = runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"PUT",
&membership_path,
attach.clone(),
StatusCode::OK,
)
.await;
assert_eq!(attached, json!([tag.clone()]));
assert_eq!(
runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"PUT",
&membership_path,
attach.clone(),
StatusCode::OK
)
.await,
attached
);
let rename = json!({"operation_key":"runtime-rename", "action":{"kind":"rename", "tag_id":tag["id"], "expected_last_operation_key":"runtime-create", "name":"Ready now"}});
let renamed = runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"POST",
&tags_path,
rename.clone(),
StatusCode::OK,
)
.await;
assert_eq!(renamed["id"], tag["id"]);
assert_eq!(renamed["name"], "Ready now");
assert_eq!(
serde_json::to_value(&backend.show(reference.id.clone().into()).unwrap().tags).unwrap(),
json!([renamed.clone()])
);
let delete = json!({"operation_key":"runtime-delete", "action":{"kind":"delete", "tag_id":tag["id"], "expected_last_operation_key":"runtime-rename"}});
let deleted = runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"POST",
&tags_path,
delete.clone(),
StatusCode::OK,
)
.await;
assert_eq!(deleted["deleted"], true);
assert!(
backend
.show(reference.id.clone().into())
.unwrap()
.tags
.is_empty()
);
// Receipts return their original snapshots, even after rename/delete changed
// live state. Retries must not recreate membership or duplicate audit events.
for (input, expected) in [(create, tag), (rename, renamed), (delete, deleted)] {
assert_eq!(
runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"POST",
&tags_path,
input,
StatusCode::OK
)
.await,
expected
);
}
assert_eq!(
runtime_tag_json(
app.clone(),
&identity,
&source.worker_id,
"PUT",
&membership_path,
attach,
StatusCode::OK
)
.await,
attached
);
runtime_tag_json(app.clone(), &identity, &source.worker_id, "PUT", &membership_path,
json!({"operation_key":"runtime-stale-set", "expected_tag_ids":[attached[0]["id"]], "tag_ids":[]}), StatusCode::CONFLICT).await;
let empty = runtime_tag_json(
app,
&identity,
&source.worker_id,
"PUT",
&membership_path,
json!({"operation_key":"runtime-empty-set", "expected_tag_ids":[], "tag_ids":[]}),
StatusCode::OK,
)
.await;
assert_eq!(empty, json!([]));
let ticket_after = backend.show(reference.id.into()).unwrap();
assert_eq!(
ticket::ticket_content_digest(&ticket_before),
ticket::ticket_content_digest(&ticket_after)
);
assert_eq!(
ticket_before.meta.workflow_state,
ticket_after.meta.workflow_state
);
assert!(ticket_after.tags.is_empty());
assert_eq!(tag_execution_authority_snapshot(&api), authority_before);
assert!(
execution.protocol_methods().is_empty(),
"classification dispatched execution/routing"
);
let tag_events: Vec<_> = ticket_after
.events
.iter()
.filter(|event| event.kind == ticket::TicketEventKind::TagsChanged)
.collect();
assert_eq!(
tag_events.len(),
2,
"attach/delete retries duplicated membership audit"
);
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
let mut statement = conn.prepare("SELECT operation_key,source_context_json FROM workspace_tag_operations WHERE workspace_id=?1 ORDER BY operation_key").unwrap();
let receipts = statement
.query_map([TEST_WORKSPACE_ID], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap();
assert_eq!(receipts.len(), 5);
for (key, context) in receipts {
let context: serde_json::Value = serde_json::from_str(&context).unwrap();
assert_eq!(
context["source_runtime_id"], source.runtime_id,
"receipt {key}: {context}"
);
assert_eq!(
context["source_worker_id"], source.worker_id,
"receipt {key}: {context}"
);
assert!(
context.get("source_account_id").is_none(),
"signed source became a human actor: {context}"
);
}
for event in tag_events {
assert_eq!(
event.attributes.get("source_runtime_id"),
Some(&source.runtime_id)
);
assert_eq!(
event.attributes.get("source_worker_id"),
Some(&source.worker_id)
);
}
}
#[tokio::test]
async fn runtime_tag_receipt_replay_is_bound_to_verified_worker_not_transport_proof() {
let dir = tempfile::tempdir().unwrap();
let mut api = test_api(dir.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-tags");
for worker in ["classifier", "other"] {
seed_worker_source_member(&api, "runtime-tags", worker);
}
let app = build_router(api.clone());
let path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
let input =
json!({"operation_key":"worker-receipt", "action":{"kind":"create", "name":"ready"}});
let request = runtime_source_request(
&identity,
Some("classifier"),
"POST",
&path,
serde_json::to_vec(&input).unwrap(),
);
let proof =
request.headers()[worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER].clone();
let tag = runtime_tag_response(app.clone(), request, StatusCode::OK).await;
runtime_tag_response(
app.clone(),
Request::builder()
.method("POST")
.uri(&path)
.header(CONTENT_TYPE, "application/json")
.header(
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
proof,
)
.body(Body::from(serde_json::to_vec(&input).unwrap()))
.unwrap(),
StatusCode::UNAUTHORIZED,
)
.await;
assert_eq!(
runtime_tag_json(
app.clone(),
&identity,
"classifier",
"POST",
&path,
input.clone(),
StatusCode::OK
)
.await,
tag
);
runtime_tag_json(
app.clone(),
&identity,
"classifier",
"POST",
&path,
json!({"operation_key":"worker-receipt", "action":{"kind":"create", "name":"changed"}}),
StatusCode::CONFLICT,
)
.await;
runtime_tag_json(
app,
&identity,
"other",
"POST",
&path,
input,
StatusCode::CONFLICT,
)
.await;
assert_eq!(
browser_ticket_backend(&api)
.unwrap()
.list_workspace_tags(ticket::TagListRequest::default())
.unwrap()
.len(),
1
);
}
#[tokio::test]
async fn runtime_tag_mutations_reject_invalid_proofs_and_changed_binding_authority() {
let dir = tempfile::tempdir().unwrap();
let (mut api, execution) = test_api_with_recording_backend(dir.path()).await;
let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
configure_runtime_request_auth(&mut api, &identity, "runtime-tags");
seed_worker_source_member(&api, "runtime-tags", "classifier");
let backend = browser_ticket_backend(&api).unwrap();
let ticket = backend
.create(ticket::NewTicket::new("Rejected classification"))
.unwrap();
let ticket_before = backend.show(ticket.id.clone().into()).unwrap();
let authority_before = tag_execution_authority_snapshot(&api);
let app = build_router(api.clone());
let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags");
let membership_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/tags", ticket.id);
let attacker = RuntimeIdentityMaterial::generate("runtime-tags").unwrap();
// Both mutation routes must reject before writing tags, memberships, receipts
// or audit events. There is no persisted verification status in this fixture.
for (method, path, input) in [
(
"POST",
tags_path.as_str(),
json!({"operation_key":"denied-create", "action":{"kind":"create", "name":"denied"}}),
),
(
"PUT",
membership_path.as_str(),
json!({"operation_key":"denied-set", "expected_tag_ids":[], "tag_ids":[]}),
),
] {
let body = serde_json::to_vec(&input).unwrap();
let unsigned = Request::builder()
.method(method)
.uri(path)
.header(CONTENT_TYPE, "application/json")
.header("x-yoi-runtime-id", "runtime-tags")
.header("x-yoi-worker-id", "classifier")
.body(Body::from(body.clone()))
.unwrap();
runtime_tag_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await;
runtime_tag_json(
app.clone(),
&attacker,
"classifier",
method,
path,
input.clone(),
StatusCode::UNAUTHORIZED,
)
.await;
runtime_tag_json(
app.clone(),
&identity,
"not-a-member",
method,
path,
input.clone(),
StatusCode::UNAUTHORIZED,
)
.await;
let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity)
.issue(
"server-test",
"another-workspace",
Some("classifier"),
worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION,
method,
path,
&body,
i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(),
30,
)
.unwrap();
runtime_tag_response(
app.clone(),
Request::builder()
.method(method)
.uri(path)
.header(CONTENT_TYPE, "application/json")
.header(
worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER,
proof,
)
.body(Body::from(body.clone()))
.unwrap(),
StatusCode::UNAUTHORIZED,
)
.await;
let mut tampered =
runtime_source_request(&identity, Some("classifier"), method, path, body);
*tampered.body_mut() = Body::from(format!("{} ", input));
runtime_tag_response(app.clone(), tampered, StatusCode::UNAUTHORIZED).await;
}
let store = SqliteWorkspaceStore::open(&api.config.database_path).unwrap();
let original = api
.store
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-tags")
.await
.unwrap()
.unwrap();
let mut replacement = original.clone();
replacement.public_key = attacker.public_key.clone();
replacement.public_key_fingerprint = String::new();
store
.upsert_workspace_runtime_binding(replacement, true)
.unwrap();
for (method, path, input) in [
(
"POST",
tags_path.as_str(),
json!({"operation_key":"stale-key-create", "action":{"kind":"create", "name":"stale"}}),
),
(
"PUT",
membership_path.as_str(),
json!({"operation_key":"stale-key-set", "expected_tag_ids":[], "tag_ids":[]}),
),
] {
runtime_tag_json(
app.clone(),
&identity,
"classifier",
method,
path,
input,
StatusCode::UNAUTHORIZED,
)
.await;
}
store
.upsert_workspace_runtime_binding(original, true)
.unwrap();
store
.revoke_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-tags", TEST_CREATED_AT)
.unwrap();
for (method, path, input) in [
(
"POST",
tags_path.as_str(),
json!({"operation_key":"revoked-create", "action":{"kind":"create", "name":"revoked"}}),
),
(
"PUT",
membership_path.as_str(),
json!({"operation_key":"revoked-set", "expected_tag_ids":[], "tag_ids":[]}),
),
] {
runtime_tag_json(
app.clone(),
&identity,
"classifier",
method,
path,
input,
StatusCode::UNAUTHORIZED,
)
.await;
}
assert!(
backend
.list_workspace_tags(ticket::TagListRequest::default())
.unwrap()
.is_empty()
);
assert_eq!(
serde_json::to_value(backend.show(ticket.id.into()).unwrap()).unwrap(),
serde_json::to_value(ticket_before).unwrap()
);
assert_eq!(tag_execution_authority_snapshot(&api), authority_before);
assert!(execution.protocol_methods().is_empty());
let conn = rusqlite::Connection::open(&api.config.database_path).unwrap();
let receipts: i64 = conn
.query_row(
"SELECT COUNT(*) FROM workspace_tag_operations WHERE workspace_id=?1",
[TEST_WORKSPACE_ID],
|row| row.get(0),
)
.unwrap();
assert_eq!(receipts, 0);
}
#[tokio::test]
async fn ticket_tags_round_trip_filter_and_preserve_execution_and_decision_projections() {
let dir = tempfile::tempdir().unwrap();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
// Included by store::tests. These fixtures prove composition with the real
// current Workspace schema-90 startup path, not a live user's database.
fn classification_rows(conn: &Connection) -> std::collections::BTreeMap<String, Vec<Vec<rusqlite::types::Value>>> {
["workspace_tags", "typed_ticket_tags", "workspace_tag_operations",
"typed_ticket_events", "typed_ticket_event_attributes", "typed_ticket_labels"]
.into_iter()
.map(|table| {
let mut statement = conn.prepare(&format!("SELECT * FROM {table} WHERE workspace_id='space' ORDER BY rowid")).unwrap();
let columns = statement.column_count();
let rows = statement.query_map([], |row| {
(0..columns).map(|column| row.get(column)).collect::<rusqlite::Result<Vec<rusqlite::types::Value>>>()
}).unwrap().collect::<rusqlite::Result<Vec<_>>>().unwrap();
(table.to_owned(), rows)
}).collect()
}
#[test]
fn current_workspace90_without_classification_upgrades_ticket7_without_rewriting_ticket_history() {
use ticket::TicketBackend;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
let store = SqliteWorkspaceStore::open(&path).unwrap();
store.with_conn(|conn| { seed_migration_workspace(conn); Ok(()) }).unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "space").unwrap();
let id = backend.create(ticket::NewTicket::new("Existing closed Ticket")).unwrap().id;
backend.close(id.clone().into(), ticket::MarkdownText::new("Existing decision, not a classification")).unwrap();
store.with_conn(|conn| {
conn.execute("INSERT INTO typed_ticket_labels VALUES ('space', ?1, 0, 'ready')", [&id])?;
Ok(())
}).unwrap();
let before = backend.show(id.clone().into()).unwrap();
assert!(before.tags.is_empty());
// Current develop has Workspace schema 90 and pre-classification Ticket
// schema 7. Strip only the not-yet-installed component from a fresh current
// fixture, leaving every Workspace FK, trigger, archive and Ticket row intact.
store.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 90);
conn.execute_batch("DROP TABLE typed_ticket_tags; DROP TABLE workspace_tag_operations; DROP TABLE workspace_tags;
DELETE FROM ticket_schema_migrations;
INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'before-tags');")?;
ticket::verify_pre_classification_ticket_schema(conn).unwrap();
Ok(())
}).unwrap();
drop(backend);
drop(store);
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
upgraded.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 90);
assert_eq!(conn.query_row("SELECT version FROM ticket_schema_migrations", [], |row| row.get::<_, i64>(0))?, 8);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, i64>(0))?, 0);
assert!(!column_exists(conn, "workspace_runtime_bindings", "state")?);
assert!(table_exists(conn, "workspace_runtime_verification_archive")?);
ticket::verify_sqlite_ticket_schema(conn).unwrap();
Ok(())
}).unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "space").unwrap();
assert_eq!(backend.show(id.clone().into()).unwrap(), before);
let ready = backend.mutate_workspace_tag(ticket::WorkspaceTagMutation { operation_key:"create-ready".into(), action:ticket::WorkspaceTagAction::Create { name:"ready".into() } }).unwrap();
backend.set_ticket_tags(id.clone().into(), ticket::TicketTagSetRequest { operation_key:"attach-ready".into(), expected_tag_ids:vec![], tag_ids:vec![ready.id] }).unwrap();
let after = backend.show(id.into()).unwrap();
assert_eq!(after.meta, before.meta);
assert_eq!(after.resolution, before.resolution);
assert_eq!(ticket::ticket_content_digest(&after), ticket::ticket_content_digest(&before));
}
#[test]
fn workspace89_to90_runtime_rebuild_preserves_tag_links_tombstones_receipts_and_audit() {
use ticket::TicketBackend;
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
prepare_retained_schema(&path, 89);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
seed_migration_workspace(&conn);
ticket::migrate_sqlite_ticket_schema(&conn).unwrap();
conn.execute_batch("INSERT INTO workspace_runtime_bindings
(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,state,authentication_mode,workspace_key_id,workspace_public_key_fingerprint,workspace_trust_id,created_at,updated_at,revoked_at)
VALUES ('space','runtime','Runtime','https://runtime.invalid','public','fingerprint','binding','verified','workspace_identity','key','workspace-fingerprint','trust','created','updated',NULL);") .unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "space").unwrap()
.with_event_attributes(std::collections::BTreeMap::from([("source_account_id".into(), "owner".into())]));
let id = backend.create(ticket::NewTicket::new("Classified before auth migration")).unwrap().id;
let ready = backend.mutate_workspace_tag(ticket::WorkspaceTagMutation {operation_key:"ready".into(),action:ticket::WorkspaceTagAction::Create{name:"ready".into()}}).unwrap();
let old = backend.mutate_workspace_tag(ticket::WorkspaceTagMutation {operation_key:"bug".into(),action:ticket::WorkspaceTagAction::Create{name:"Bug".into()}}).unwrap();
let attach = ticket::TicketTagSetRequest {operation_key:"attach".into(),expected_tag_ids:vec![],tag_ids:vec![ready.id.clone(),old.id.clone()]};
let receipt = backend.set_ticket_tags(id.clone().into(), attach.clone()).unwrap();
let deleted = backend.mutate_workspace_tag(ticket::WorkspaceTagMutation {operation_key:"delete".into(),action:ticket::WorkspaceTagAction::Delete{tag_id:old.id.clone(),expected_last_operation_key:old.last_operation_key}}).unwrap();
assert!(deleted.deleted);
let replacement = backend.mutate_workspace_tag(ticket::WorkspaceTagMutation {operation_key:"replacement".into(),action:ticket::WorkspaceTagAction::Create{name:"Bug".into()}}).unwrap();
assert_ne!(replacement.id, old.id);
let before = backend.show(id.clone().into()).unwrap();
let snapshots = classification_rows(&conn);
drop(conn);
drop(backend);
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
upgraded.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 90);
assert_eq!(classification_rows(conn), snapshots);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, i64>(0))?, 0);
assert_eq!(conn.query_row("SELECT state FROM workspace_runtime_binding_verification_archive WHERE workspace_id='space' AND runtime_id='runtime'", [], |row| row.get::<_, String>(0))?, "verified");
assert!(!column_exists(conn, "workspace_runtime_bindings", "state")?);
ticket::verify_sqlite_ticket_schema(conn).unwrap();
// Composite FKs still reject foreign Ticket/tag linkage after rebuild.
assert!(conn.execute("INSERT INTO typed_ticket_tags VALUES ('other','missing',?1)", [&ready.id]).is_err());
Ok(())
}).unwrap();
let backend = ticket::SqliteTicketBackend::open_verified(&path, "space").unwrap()
.with_event_attributes(std::collections::BTreeMap::from([("source_account_id".into(), "owner".into())]));
assert_eq!(backend.show(id.clone().into()).unwrap(), before);
assert_eq!(backend.set_ticket_tags(id.clone().into(), attach).unwrap(), receipt);
assert!(backend.set_ticket_tags(id.into(), ticket::TicketTagSetRequest {operation_key:"deleted-reference".into(),expected_tag_ids:vec![ready.id],tag_ids:vec![old.id]}).is_err());
upgraded.with_conn(|conn| { assert_eq!(classification_rows(conn), snapshots); Ok(()) }).unwrap();
}
@@ -102,7 +102,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"workspace_resource_keys",
"workspace_runtime_binding_audit",
"workspace_runtime_bindings",
"workspace_runtime_verifications",
"workspace_runtime_binding_verification_archive",
"workspace_runtime_verification_archive",
"workspace_signing_identities",
"workspace_signing_identity_audit",
"workspace_signing_identity_provisioning_operations",
@@ -228,36 +228,7 @@ impl WorkspaceSigningIdentityService {
}
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
let identity = self.get_validated(workspace_id)?;
if identity.state != "active" {
return Err(identity_error(
"workspace_signing_identity_not_provisioned",
"Workspace signing identity is not provisioned",
));
}
let material = self
.materials
.load(&identity.private_material_ref)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing",
)
})?;
let fingerprint = identity.public_key_fingerprint.as_deref().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing public key fingerprint is missing",
)
})?;
let signing_key = material.signing_key(workspace_id, &identity.key_id)?;
let public_key = encode_public_key(signing_key.public_key().as_ref());
if public_key_fingerprint(&public_key)? != fingerprint {
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match public metadata",
));
}
let (_, signing_key) = self.select_signing_key(workspace_id)?;
Ok(signing_key.sign(payload).as_ref().to_vec())
}
@@ -266,9 +237,20 @@ impl WorkspaceSigningIdentityService {
workspace_id: &str,
claims: &WorkspaceCapabilityClaims,
) -> Result<String> {
let (identity, signing_key) = self.select_signing_key(workspace_id)?;
if claims.issuer_workspace_id != identity.workspace_id
|| claims.issuer_key_id != identity.key_id
|| Some(claims.issuer_public_key_fingerprint.as_str())
!= identity.public_key_fingerprint.as_deref()
{
return Err(identity_error(
"workspace_capability_identity_mismatch",
"Workspace capability claims do not match the current signing identity",
));
}
let input = workspace_capability_signing_input(claims).map_err(capability_error)?;
let signature = self.sign(workspace_id, input.bytes())?;
assemble_workspace_capability_token(input, &signature).map_err(capability_error)
let signature = signing_key.sign(input.bytes());
assemble_workspace_capability_token(input, signature.as_ref()).map_err(capability_error)
}
pub fn delete_material(&self, workspace_id: &str) -> Result<()> {
@@ -306,7 +288,40 @@ impl WorkspaceSigningIdentityService {
})
}
fn select_signing_key(
&self,
workspace_id: &str,
) -> Result<(
WorkspaceSigningIdentityRecord,
ring::signature::Ed25519KeyPair,
)> {
let identity = self
.store
.get_workspace_signing_identity(workspace_id)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_metadata_missing",
"Workspace signing identity metadata is missing",
)
})?;
if identity.state != "active" {
return Err(identity_error(
"workspace_signing_identity_not_provisioned",
"Workspace signing identity is not provisioned",
));
}
let signing_key = self.validated_signing_key(&identity)?;
Ok((identity, signing_key))
}
fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> {
self.validated_signing_key(identity).map(|_| ())
}
fn validated_signing_key(
&self,
identity: &WorkspaceSigningIdentityRecord,
) -> Result<ring::signature::Ed25519KeyPair> {
let material = self
.materials
.load(&identity.private_material_ref)?
@@ -316,8 +331,8 @@ impl WorkspaceSigningIdentityService {
"Workspace signing private material is missing",
)
})?;
let public_key =
material.validate_and_public_key(&identity.workspace_id, &identity.key_id)?;
let signing_key = material.signing_key(&identity.workspace_id, &identity.key_id)?;
let public_key = encode_public_key(signing_key.public_key().as_ref());
let fingerprint = public_key_fingerprint(&public_key)?;
if identity.public_key.as_deref() != Some(public_key.as_str())
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str())
@@ -327,7 +342,7 @@ impl WorkspaceSigningIdentityService {
"Workspace signing private material does not match public metadata",
));
}
Ok(())
Ok(signing_key)
}
}
@@ -843,6 +858,14 @@ mod tests {
loads: std::sync::atomic::AtomicUsize::new(0),
}),
);
let selected_signature = swapping_service.sign("workspace-1", payload).unwrap();
ring::signature::UnparsedPublicKey::new(
&ring::signature::ED25519,
worker_runtime::auth::decode_public_key(provisioned.public_key.as_deref().unwrap())
.unwrap(),
)
.verify(payload, &selected_signature)
.unwrap();
assert!(matches!(
swapping_service.sign("workspace-1", payload),
Err(Error::WorkspaceSigningIdentity { ref code, .. })