merge: compose current auth migration with Ticket case schema 91
This commit is contained in:
@@ -1139,10 +1139,6 @@ pub struct RemainingRuntimeRoute {
|
|||||||
pub reason: &'static str,
|
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_BUNDLES: &str = "/v1/config-bundles";
|
||||||
pub const RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY: &str =
|
pub const RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY: &str =
|
||||||
"/v1/config-bundles/{bundle_id}/availability";
|
"/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
|
/// Intentionally out-of-contract Runtime routes. This inventory keeps manual paths visible rather
|
||||||
/// than letting them be mistaken for management-contract omissions.
|
/// than letting them be mistaken for management-contract omissions.
|
||||||
pub const REMAINING_RUNTIME_ROUTES: &[RemainingRuntimeRoute] = &[
|
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 {
|
RemainingRuntimeRoute {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: RUNTIME_ROUTE_CONFIG_BUNDLES,
|
path: RUNTIME_ROUTE_CONFIG_BUNDLES,
|
||||||
@@ -1625,7 +1611,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn remaining_route_inventory_does_not_overlap_contract() {
|
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 {
|
for remaining in REMAINING_RUNTIME_ROUTES {
|
||||||
assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| {
|
assert!(!RuntimeApiMetadata::OPERATIONS.iter().any(|operation| {
|
||||||
format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method)
|
format!("{:?}", operation.method).eq_ignore_ascii_case(remaining.method)
|
||||||
|
|||||||
@@ -8842,62 +8842,14 @@ pub struct RuntimeSummary {
|
|||||||
pub diagnostics: Vec<Diagnostic>,
|
pub diagnostics: Vec<Diagnostic>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
/// Binding identity and explicit revocation only. Each signed request authenticates
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
/// using the current Workspace signing identity, not a stored approval.
|
||||||
#[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,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct WorkspaceRuntimeBindingSummary {
|
pub struct WorkspaceRuntimeBindingSummary {
|
||||||
pub state: WorkspaceRuntimeBindingState,
|
|
||||||
pub connection_state: RuntimeConnectionDisplayState,
|
|
||||||
pub binding_id: String,
|
pub binding_id: String,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
pub revoked_at: Option<String>,
|
||||||
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>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
@@ -9081,9 +9033,6 @@ pub struct RuntimePublicIdentityBundle {
|
|||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CreateRemoteRuntimeRequest {
|
pub struct CreateRemoteRuntimeRequest {
|
||||||
pub public_bundle: RuntimePublicIdentityBundle,
|
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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub endpoint: String,
|
pub endpoint: String,
|
||||||
@@ -9126,12 +9075,11 @@ pub enum RuntimeConnectionTestFailureKind {
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
|
/// Observation of an actual signed ping, not a grant or cached approval.
|
||||||
pub struct RuntimeConnectionTestResponse {
|
pub struct RuntimeConnectionTestResponse {
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub runtime_id: String,
|
pub runtime_id: String,
|
||||||
pub binding_id: String,
|
pub binding_id: String,
|
||||||
pub connection_state: RuntimeConnectionDisplayState,
|
|
||||||
pub verification: Option<RuntimeVerificationEvidenceSummary>,
|
|
||||||
pub checked_at: String,
|
pub checked_at: String,
|
||||||
pub status: RuntimeConnectionTestStatus,
|
pub status: RuntimeConnectionTestStatus,
|
||||||
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
|
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
|
||||||
@@ -12626,18 +12574,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_registration_requires_explicit_runtime_issued_trust_identity() {
|
fn runtime_registration_uses_runtime_bundle_without_workspace_identity_copies() {
|
||||||
let mut value = serde_json::json!({
|
let value = serde_json::json!({
|
||||||
"public_bundle": {"identity_id": "runtime", "public_key": "public-key"},
|
"public_bundle": {"identity_id": "runtime", "public_key": "public-key"},
|
||||||
"endpoint": "https://runtime.example.test",
|
"endpoint": "https://runtime.example.test"
|
||||||
"workspace_trust_id": "runtime-issued-enrollment"
|
|
||||||
});
|
});
|
||||||
let request: CreateRemoteRuntimeRequest = serde_json::from_value(value.clone()).unwrap();
|
let request: CreateRemoteRuntimeRequest = serde_json::from_value(value.clone()).unwrap();
|
||||||
assert_eq!(request.workspace_trust_id, "runtime-issued-enrollment");
|
assert_eq!(serde_json::to_value(request).unwrap(), value);
|
||||||
value.as_object_mut().unwrap().remove("workspace_trust_id");
|
for field in [
|
||||||
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(value.clone()).is_err());
|
"workspace_trust_id",
|
||||||
value["workspace_key_generation"] = serde_json::json!(1);
|
"workspace_key_id",
|
||||||
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(value).is_err());
|
"workspace_key_generation",
|
||||||
|
] {
|
||||||
|
let mut obsolete = value.clone();
|
||||||
|
obsolete[field] = serde_json::json!("obsolete");
|
||||||
|
assert!(serde_json::from_value::<CreateRemoteRuntimeRequest>(obsolete).is_err());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -12646,8 +12598,6 @@ mod tests {
|
|||||||
"workspace_id": "workspace-test",
|
"workspace_id": "workspace-test",
|
||||||
"runtime_id": "runtime-test",
|
"runtime_id": "runtime-test",
|
||||||
"binding_id": "binding-test",
|
"binding_id": "binding-test",
|
||||||
"connection_state": "verified",
|
|
||||||
"verification": null,
|
|
||||||
"checked_at": "2026-09-01T12:00:00Z",
|
"checked_at": "2026-09-01T12:00:00Z",
|
||||||
"status": "compatible",
|
"status": "compatible",
|
||||||
"failure_kind": null,
|
"failure_kind": null,
|
||||||
@@ -12665,21 +12615,22 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn runtime_trust_contract_binds_concrete_keys_and_binding_lifetimes() {
|
fn runtime_binding_rejects_cached_authentication_and_requires_observed_identity_for_mutation() {
|
||||||
let evidence = RuntimeVerificationEvidenceSummary {
|
let binding = serde_json::json!({"binding_id": "binding-new", "revoked_at": null});
|
||||||
verified_at: Some("2026-10-06T00:00:00Z".into()),
|
let parsed: WorkspaceRuntimeBindingSummary =
|
||||||
last_checked_at: "2026-10-06T00:00:00Z".into(),
|
serde_json::from_value(binding.clone()).unwrap();
|
||||||
last_outcome: RuntimeVerificationOutcome::Verified,
|
assert_eq!(serde_json::to_value(parsed).unwrap(), binding);
|
||||||
binding_id: "binding-new".into(),
|
for field in [
|
||||||
workspace_key_id: "workspace-key".into(),
|
"state",
|
||||||
workspace_public_key_fingerprint: "sha256:workspace-key".into(),
|
"connection_state",
|
||||||
workspace_trust_id: "trust-new".into(),
|
"workspace_key_id",
|
||||||
runtime_public_key_fingerprint: "sha256:runtime-key".into(),
|
"workspace_trust_id",
|
||||||
};
|
"verification",
|
||||||
round_trip(evidence.clone());
|
] {
|
||||||
let mut stale = serde_json::to_value(evidence).unwrap();
|
let mut obsolete = binding.clone();
|
||||||
stale["unexpected_state_guard"] = serde_json::json!(1);
|
obsolete[field] = serde_json::json!("obsolete");
|
||||||
assert!(serde_json::from_value::<RuntimeVerificationEvidenceSummary>(stale).is_err());
|
assert!(serde_json::from_value::<WorkspaceRuntimeBindingSummary>(obsolete).is_err());
|
||||||
|
}
|
||||||
round_trip(RevokeRuntimeTrustKeyRequest {
|
round_trip(RevokeRuntimeTrustKeyRequest {
|
||||||
expected_binding_id: "binding-observed".into(),
|
expected_binding_id: "binding-observed".into(),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,8 @@ pub enum LoggedSessionHistoryOrigin {
|
|||||||
ToolOutput {
|
ToolOutput {
|
||||||
worker: LoggedWorkerSubject,
|
worker: LoggedWorkerSubject,
|
||||||
},
|
},
|
||||||
|
/// Host-produced opening context summary; not auto-read or TaskStore material.
|
||||||
|
CompactionSummary,
|
||||||
DerivedSummary,
|
DerivedSummary,
|
||||||
LegacyUnknown,
|
LegacyUnknown,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1877,7 +1877,8 @@ fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance {
|
|||||||
}
|
}
|
||||||
LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
|
LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
|
||||||
LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
|
LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
|
||||||
LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
|
LoggedSessionHistoryOrigin::CompactionSummary
|
||||||
|
| LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary,
|
||||||
LoggedSessionHistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
|
LoggedSessionHistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2548,6 +2549,12 @@ mod tests {
|
|||||||
user_message(10),
|
user_message(10),
|
||||||
assistant_message(10, "final"),
|
assistant_message(10, "final"),
|
||||||
synthetic_compaction_input,
|
synthetic_compaction_input,
|
||||||
|
history_message(
|
||||||
|
"host-summary",
|
||||||
|
LoggedRole::System,
|
||||||
|
"[Compacted context summary]\n\nprivate inherited context",
|
||||||
|
LoggedSessionHistoryOrigin::CompactionSummary,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
None,
|
None,
|
||||||
Some(SegmentOrigin {
|
Some(SegmentOrigin {
|
||||||
|
|||||||
@@ -27,4 +27,19 @@ cargo run -p worker-runtime \
|
|||||||
```
|
```
|
||||||
|
|
||||||
The REST server is intended for a trusted Backend/proxy, not direct browser access.
|
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).
|
For authenticated remote Runtime setup, see [`../../docs/development/server-runtime-auth.md`](../../docs/development/server-runtime-auth.md).
|
||||||
|
|||||||
@@ -31,11 +31,7 @@ use crate::ssh_host_key_probe::{
|
|||||||
SshHostKeyProbeRequest, SshHostKeyProbeResponse, probe_ssh_host_keys_with_program,
|
SshHostKeyProbeRequest, SshHostKeyProbeResponse, probe_ssh_host_keys_with_program,
|
||||||
};
|
};
|
||||||
use crate::workspace_issuer::{
|
use crate::workspace_issuer::{
|
||||||
RuntimeVerificationSigner, VerifiedWorkspaceCapability, WORKSPACE_VERIFICATION_OPERATION,
|
|
||||||
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerifier,
|
WorkspaceCapabilityExpectation, WorkspaceCapabilityVerifier,
|
||||||
WorkspaceRuntimeVerificationAcknowledgement, WorkspaceRuntimeVerificationAuthority,
|
|
||||||
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
|
|
||||||
WorkspaceRuntimeVerificationRecord, WorkspaceRuntimeVerificationResponse,
|
|
||||||
inspect_workspace_capability_claims, workspace_request_body_digest,
|
inspect_workspace_capability_claims, workspace_request_body_digest,
|
||||||
};
|
};
|
||||||
use crate::{Runtime, RuntimeWorkspaceScope};
|
use crate::{Runtime, RuntimeWorkspaceScope};
|
||||||
@@ -256,14 +252,6 @@ fn runtime_http_router_with_auth_and_ssh_keyscan_program(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let router = Router::new()
|
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(
|
.route(
|
||||||
remaining_route(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES, &["GET", "POST"]),
|
remaining_route(runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLES, &["GET", "POST"]),
|
||||||
get(list_config_bundles).post(store_config_bundle),
|
get(list_config_bundles).post(store_config_bundle),
|
||||||
@@ -377,8 +365,7 @@ struct RuntimeHttpState {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct WorkspaceRuntimeHttpAuth {
|
pub struct WorkspaceRuntimeHttpAuth {
|
||||||
pub verifier: WorkspaceCapabilityVerifier,
|
pub verifier: WorkspaceCapabilityVerifier,
|
||||||
pub signer: RuntimeVerificationSigner,
|
pub runtime_id: String,
|
||||||
pub verifications: Arc<dyn WorkspaceRuntimeVerificationAuthority>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct RuntimeHttpWorkdirSession {
|
struct RuntimeHttpWorkdirSession {
|
||||||
@@ -563,172 +550,6 @@ fn unix_now_i64() -> i64 {
|
|||||||
i64::try_from(unix_now_seconds()).unwrap_or(i64::MAX)
|
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)]
|
#[allow(dead_code)]
|
||||||
async fn get_runtime_ping(
|
async fn get_runtime_ping(
|
||||||
State(state): State<RuntimeHttpState>,
|
State(state): State<RuntimeHttpState>,
|
||||||
@@ -757,7 +578,7 @@ async fn get_runtime_ping(
|
|||||||
let runtime_id = state
|
let runtime_id = state
|
||||||
.workspace_auth
|
.workspace_auth
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|auth| auth.signer.runtime_id().trim())
|
.map(|auth| auth.runtime_id.trim())
|
||||||
.filter(|runtime_id| !runtime_id.is_empty())
|
.filter(|runtime_id| !runtime_id.is_empty())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
RuntimeHttpRestError::new(
|
RuntimeHttpRestError::new(
|
||||||
@@ -2123,8 +1944,7 @@ async fn require_runtime_auth(
|
|||||||
let body_digest = workspace_request_body_digest(&body);
|
let body_digest = workspace_request_body_digest(&body);
|
||||||
let expected = WorkspaceCapabilityExpectation {
|
let expected = WorkspaceCapabilityExpectation {
|
||||||
workspace_id: &claims.issuer_workspace_id,
|
workspace_id: &claims.issuer_workspace_id,
|
||||||
binding_id: &claims.binding_id,
|
runtime_id: &workspace_auth.runtime_id,
|
||||||
runtime_id: workspace_auth.signer.runtime_id(),
|
|
||||||
worker_id: expected_worker_id.as_deref(),
|
worker_id: expected_worker_id.as_deref(),
|
||||||
operation: required_permission,
|
operation: required_permission,
|
||||||
method: &method,
|
method: &method,
|
||||||
@@ -2134,48 +1954,6 @@ async fn require_runtime_auth(
|
|||||||
};
|
};
|
||||||
match workspace_auth.verifier.verify(token, &expected) {
|
match workspace_auth.verifier.verify(token, &expected) {
|
||||||
Ok(verified) => {
|
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 = Request::from_parts(parts, Body::from(body));
|
||||||
request.extensions_mut().insert(verified.clone());
|
request.extensions_mut().insert(verified.clone());
|
||||||
request.extensions_mut().insert(RuntimeAuthContext {
|
request.extensions_mut().insert(RuntimeAuthContext {
|
||||||
@@ -2199,13 +1977,7 @@ async fn require_runtime_auth(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let workspace_bootstrap_request = request.method() == Method::POST
|
if state.workspace_auth.is_some() {
|
||||||
&& 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 {
|
|
||||||
let local_token_matches = state
|
let local_token_matches = state
|
||||||
.local_token
|
.local_token
|
||||||
.as_deref()
|
.as_deref()
|
||||||
@@ -2275,12 +2047,6 @@ fn auth_workspace_scope(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_runtime_operation(method: &Method, path: &str) -> &'static str {
|
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")
|
required_runtime_permission(method, path).unwrap_or("runtime:read")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2661,12 +2427,10 @@ mod tests {
|
|||||||
WorkerExecutionResult, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
WorkerExecutionResult, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
|
||||||
};
|
};
|
||||||
use crate::management::RuntimeOptions;
|
use crate::management::RuntimeOptions;
|
||||||
use crate::retention::{DiagnosticsDisposition, SessionDisposition};
|
|
||||||
use crate::workspace_issuer::{
|
use crate::workspace_issuer::{
|
||||||
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
|
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityClaims,
|
||||||
WorkspaceCapabilityClaims, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
|
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
|
||||||
WorkspaceIssuerTrustState, issue_workspace_capability_token,
|
issue_workspace_capability_token,
|
||||||
verify_runtime_verification_response,
|
|
||||||
};
|
};
|
||||||
use axum::body::to_bytes;
|
use axum::body::to_bytes;
|
||||||
use axum::http::Method;
|
use axum::http::Method;
|
||||||
@@ -2741,8 +2505,6 @@ mod tests {
|
|||||||
.map(|route| route.path)
|
.map(|route| route.path)
|
||||||
.collect::<std::collections::BTreeSet<_>>();
|
.collect::<std::collections::BTreeSet<_>>();
|
||||||
for path in [
|
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_BUNDLES,
|
||||||
runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY,
|
runtime_api::RUNTIME_ROUTE_CONFIG_BUNDLE_AVAILABILITY,
|
||||||
runtime_api::RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS,
|
runtime_api::RUNTIME_ROUTE_WORKSPACE_PROMPT_PROJECTIONS,
|
||||||
@@ -2766,402 +2528,286 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
fn workspace_trust(identity: &RuntimeIdentityMaterial) -> WorkspaceIssuerTrustRecord {
|
||||||
async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() {
|
WorkspaceIssuerTrustRecord::from_bundle(
|
||||||
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(
|
|
||||||
server_api::WorkspacePublicIdentityBundle {
|
server_api::WorkspacePublicIdentityBundle {
|
||||||
workspace_id: "workspace-a".into(),
|
workspace_id: "workspace-a".into(),
|
||||||
backend_url: "https://backend.test".into(),
|
backend_url: "https://backend.test".into(),
|
||||||
key_id: "workspace-key".into(),
|
key_id: identity.identity_id.clone(),
|
||||||
algorithm: "ed25519".into(),
|
algorithm: "ed25519".into(),
|
||||||
public_key: workspace_identity.public_key.clone(),
|
public_key: identity.public_key.clone(),
|
||||||
public_key_fingerprint: fingerprint(&workspace_identity.public_key),
|
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,
|
1,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap()
|
||||||
let old_verification = WorkspaceRuntimeVerificationRecord {
|
}
|
||||||
workspace_id: old_trust.workspace_id.clone(),
|
|
||||||
|
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(),
|
runtime_id: "runtime-test".into(),
|
||||||
binding_id: "old-binding".into(),
|
worker_id: worker_id_from_runtime_path(path),
|
||||||
workspace_key_id: old_trust.key_id.clone(),
|
operation: workspace_runtime_operation(
|
||||||
workspace_public_key_fingerprint: old_trust.public_key_fingerprint.clone(),
|
&Method::from_bytes(method.as_bytes()).unwrap(),
|
||||||
workspace_trust_id: old_trust.trust_id.clone(),
|
path,
|
||||||
runtime_public_key_fingerprint: fingerprint(&runtime_identity.public_key),
|
)
|
||||||
verified_at: 1,
|
.into(),
|
||||||
};
|
method: method.into(),
|
||||||
for changed in ["binding", "trust", "workspace_key", "runtime_key"] {
|
path_and_query: path.into(),
|
||||||
let mut current = old_verification.clone();
|
body_digest: workspace_request_body_digest(body),
|
||||||
let mut trust = old_trust.clone();
|
iat: unix_now_i64(),
|
||||||
let mut workspace_key = workspace_identity.clone();
|
exp: unix_now_i64() + 60,
|
||||||
let mut runtime_key = runtime_identity.clone();
|
jti: uuid::Uuid::now_v7().to_string(),
|
||||||
match changed {
|
}
|
||||||
"binding" => current.binding_id = "reenrolled-binding".into(),
|
}
|
||||||
"trust" => {
|
|
||||||
trust.trust_id = "reaccepted-trust".into();
|
fn workspace_test_auth(records: Vec<WorkspaceIssuerTrustRecord>) -> WorkspaceRuntimeHttpAuth {
|
||||||
current.workspace_trust_id = trust.trust_id.clone();
|
WorkspaceRuntimeHttpAuth {
|
||||||
}
|
runtime_id: "runtime-test".into(),
|
||||||
"workspace_key" => {
|
verifier: WorkspaceCapabilityVerifier::new(
|
||||||
workspace_key = RuntimeIdentityMaterial::generate("new-workspace-key").unwrap();
|
records,
|
||||||
trust.key_id = workspace_key.identity_id.clone();
|
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||||
trust.public_key = workspace_key.public_key.clone();
|
)
|
||||||
trust.public_key_fingerprint = fingerprint(&workspace_key.public_key);
|
.unwrap(),
|
||||||
current.workspace_key_id = trust.key_id.clone();
|
}
|
||||||
current.workspace_public_key_fingerprint = trust.public_key_fingerprint.clone();
|
}
|
||||||
}
|
|
||||||
"runtime_key" => {
|
async fn signed_request(
|
||||||
runtime_key = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
|
app: Router,
|
||||||
current.runtime_public_key_fingerprint = fingerprint(&runtime_key.public_key);
|
identity: &RuntimeIdentityMaterial,
|
||||||
}
|
claims: &WorkspaceCapabilityClaims,
|
||||||
_ => unreachable!(),
|
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());
|
let records = if case == "untrusted" {
|
||||||
authority.record(old_verification.clone()).unwrap();
|
vec![]
|
||||||
|
} else {
|
||||||
|
vec![current]
|
||||||
|
};
|
||||||
let app = runtime_http_router_with_workspace_auth(
|
let app = runtime_http_router_with_workspace_auth(
|
||||||
Runtime::new_memory(),
|
Runtime::new_memory(),
|
||||||
None,
|
None,
|
||||||
WorkspaceRuntimeHttpAuth {
|
workspace_test_auth(records),
|
||||||
verifier: WorkspaceCapabilityVerifier::new(
|
|
||||||
vec![trust.clone()],
|
|
||||||
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
|
||||||
)
|
|
||||||
.unwrap(),
|
|
||||||
signer: RuntimeVerificationSigner::from_identity(&runtime_key).unwrap(),
|
|
||||||
verifications: authority.clone(),
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
let claims = WorkspaceCapabilityClaims {
|
let mut claims = signed_claims(&trust, "GET", "/v1/ping", &[]);
|
||||||
issuer: trust.backend_url.clone(),
|
match case {
|
||||||
issuer_workspace_id: trust.workspace_id.clone(),
|
"wrong_key" => claims.issuer_key_id = "untrusted-key".into(),
|
||||||
issuer_key_id: trust.key_id.clone(),
|
"wrong_runtime" => claims.runtime_id = "another-runtime".into(),
|
||||||
issuer_public_key_fingerprint: trust.public_key_fingerprint.clone(),
|
"wrong_operation" => claims.operation = "workers:create".into(),
|
||||||
trust_id: trust.trust_id.clone(),
|
"expired" => {
|
||||||
binding_id: current.binding_id.clone(),
|
claims.iat -= 120;
|
||||||
runtime_id: current.runtime_id.clone(),
|
claims.exp -= 120;
|
||||||
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 token = issue_workspace_capability_token(
|
_ => {}
|
||||||
&workspace_key.signing_key().unwrap(),
|
}
|
||||||
&WorkspaceCapabilityClaims {
|
let response = if case == "unsigned" {
|
||||||
jti: token_id.into(),
|
app.oneshot(
|
||||||
..claims.clone()
|
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();
|
.await
|
||||||
let response = app
|
};
|
||||||
.clone()
|
let status = response.status();
|
||||||
.oneshot(
|
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||||
Request::builder()
|
assert_eq!(
|
||||||
.uri("/v1/ping")
|
status,
|
||||||
.header(header::AUTHORIZATION, format!("Bearer {token}"))
|
if case == "wrong_workspace_scope" {
|
||||||
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
|
StatusCode::FORBIDDEN
|
||||||
.body(Body::empty())
|
} else {
|
||||||
.unwrap(),
|
StatusCode::UNAUTHORIZED
|
||||||
)
|
},
|
||||||
.await
|
"{case}: {}",
|
||||||
.unwrap();
|
String::from_utf8_lossy(&bytes)
|
||||||
let status = response.status();
|
);
|
||||||
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
let error: RuntimeHttpErrorResponse = serde_json::from_slice(&bytes).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
status,
|
error.error.code,
|
||||||
expected,
|
if case == "wrong_workspace_scope" {
|
||||||
"{changed}: {}",
|
"runtime_ping_workspace_scope_mismatch"
|
||||||
String::from_utf8_lossy(&body)
|
} else {
|
||||||
);
|
"unauthorized"
|
||||||
if expected == StatusCode::FORBIDDEN {
|
},
|
||||||
let error: RuntimeHttpErrorResponse = serde_json::from_slice(&body).unwrap();
|
"{case}"
|
||||||
assert_eq!(error.error.code, "workspace_runtime_verification_stale");
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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()),
|
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
|
||||||
)
|
)
|
||||||
.unwrap(),
|
.unwrap(),
|
||||||
signer: RuntimeVerificationSigner::from_identity(&identity).unwrap(),
|
runtime_id: identity.identity_id.clone(),
|
||||||
verifications: Arc::new(InMemoryWorkspaceRuntimeVerificationAuthority::default()),
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
(runtime, app)
|
(runtime, app)
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
|||||||
.state
|
.state
|
||||||
.workspace_auth
|
.workspace_auth
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|auth| auth.signer.runtime_id().trim())
|
.map(|auth| auth.runtime_id.trim())
|
||||||
.filter(|runtime_id| !runtime_id.is_empty())
|
.filter(|runtime_id| !runtime_id.is_empty())
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
runtime_api::RuntimeApiError::new(
|
runtime_api::RuntimeApiError::new(
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ use worker_runtime::http_server::{
|
|||||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||||
use worker_runtime::working_directory::RuntimeGitMaterializer;
|
use worker_runtime::working_directory::RuntimeGitMaterializer;
|
||||||
use worker_runtime::workspace_issuer::{
|
use worker_runtime::workspace_issuer::{
|
||||||
FileWorkspaceClaimReplayProtection, FileWorkspaceRuntimeVerificationAuthority,
|
FileWorkspaceClaimReplayProtection, MAX_WORKSPACE_ISSUER_TRUST_RECORDS,
|
||||||
MAX_WORKSPACE_ISSUER_TRUST_RECORDS, RuntimeVerificationSigner, WorkspaceCapabilityVerifier,
|
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation,
|
||||||
WorkspaceIssuerTrustError, WorkspaceIssuerTrustMutation, WorkspaceIssuerTrustRecord,
|
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, add_workspace_issuer_trust,
|
||||||
WorkspaceIssuerTrustState, add_workspace_issuer_trust, replace_workspace_issuer_trust,
|
replace_workspace_issuer_trust, revoke_workspace_issuer_trust,
|
||||||
revoke_workspace_issuer_trust, validate_workspace_issuer_trust_records,
|
validate_workspace_issuer_trust_records,
|
||||||
};
|
};
|
||||||
use worker_runtime::workspace_request::RuntimeWorkspaceRequestClient;
|
use worker_runtime::workspace_request::RuntimeWorkspaceRequestClient;
|
||||||
use worker_runtime::{Runtime, RuntimeOptions};
|
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)
|
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
|
||||||
.map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?;
|
.map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?;
|
||||||
if migrated {
|
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)?;
|
write_runtime_auth_file(path, &auth)?;
|
||||||
}
|
}
|
||||||
Ok(auth)
|
Ok(auth)
|
||||||
@@ -1025,25 +1025,15 @@ fn load_workspace_runtime_http_auth(
|
|||||||
let Some(identity) = auth.identity else {
|
let Some(identity) = auth.identity else {
|
||||||
return Ok(None);
|
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 replay_path = runtime_auth_path(config).with_extension("workspace-replay.json");
|
||||||
let verifier = WorkspaceCapabilityVerifier::new(
|
let verifier = WorkspaceCapabilityVerifier::new(
|
||||||
auth.workspace_issuers,
|
auth.workspace_issuers,
|
||||||
Arc::new(FileWorkspaceClaimReplayProtection::new(replay_path)),
|
Arc::new(FileWorkspaceClaimReplayProtection::new(replay_path)),
|
||||||
)
|
)
|
||||||
.map_err(|error| ProcessError::auth(format!("invalid Workspace issuer trust: {error}")))?;
|
.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 {
|
Ok(Some(WorkspaceRuntimeHttpAuth {
|
||||||
verifier,
|
verifier,
|
||||||
signer,
|
runtime_id: identity.identity_id,
|
||||||
verifications: Arc::new(FileWorkspaceRuntimeVerificationAuthority::new(
|
|
||||||
verifications_path,
|
|
||||||
)),
|
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1407,6 +1397,34 @@ mod tests {
|
|||||||
assert_eq!(std::fs::read(root.join("runtime.json")).unwrap(), before);
|
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]
|
#[test]
|
||||||
fn workspace_issuer_trust_cli_persists_across_reload_and_writes_private_mode() {
|
fn workspace_issuer_trust_cli_persists_across_reload_and_writes_private_mode() {
|
||||||
use server_api::WorkspacePublicIdentityBundle;
|
use server_api::WorkspacePublicIdentityBundle;
|
||||||
@@ -1454,38 +1472,6 @@ mod tests {
|
|||||||
let first = read_runtime_auth_file(&path).unwrap();
|
let first = read_runtime_auth_file(&path).unwrap();
|
||||||
assert_eq!(first.workspace_issuers.len(), 1);
|
assert_eq!(first.workspace_issuers.len(), 1);
|
||||||
assert!(!first.workspace_issuers[0].trust_id.is_empty());
|
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([
|
run_trust_workspace_command(VecDeque::from([
|
||||||
"add".to_string(),
|
"add".to_string(),
|
||||||
"--bundle".to_string(),
|
"--bundle".to_string(),
|
||||||
@@ -1645,9 +1631,14 @@ mod tests {
|
|||||||
assert_eq!(error, "invalid trust-workspace list arguments");
|
assert_eq!(error, "invalid trust-workspace list arguments");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[tokio::test]
|
||||||
fn pre_id_issuer_migration_retains_history_without_restoring_authority() {
|
async fn pre_id_issuer_migration_preserves_saved_permission_for_ordinary_signed_ping() {
|
||||||
use sha2::{Digest as _, Sha256};
|
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 identity = RuntimeIdentityMaterial::generate("WK-legacy").unwrap();
|
||||||
let public_key = decode_public_key(&identity.public_key).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();
|
std::fs::write(&path, toml::to_string(&document).unwrap()).unwrap();
|
||||||
|
|
||||||
let migrated = read_runtime_auth_file(&path).unwrap();
|
let migrated = read_runtime_auth_file(&path).unwrap();
|
||||||
assert_eq!(
|
let expected_state = if state == "active" {
|
||||||
migrated.workspace_issuers[0].state,
|
WorkspaceIssuerTrustState::Active
|
||||||
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
|
} 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);
|
assert_eq!(migrated.archived_legacy_issuers.len(), 1);
|
||||||
let archived: toml::Value =
|
let archived: toml::Value =
|
||||||
toml::from_str(&migrated.archived_legacy_issuers[0]).unwrap();
|
toml::from_str(&migrated.archived_legacy_issuers[0]).unwrap();
|
||||||
assert_eq!(archived, original_record);
|
assert_eq!(archived, original_record);
|
||||||
write_runtime_auth_file(&path, &migrated).unwrap();
|
write_runtime_auth_file(&path, &migrated).unwrap();
|
||||||
assert_eq!(read_runtime_auth_file(&path).unwrap(), migrated);
|
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
@@ -379,6 +379,7 @@ pub(crate) struct CompactWorkerInterceptor {
|
|||||||
pub on_warning: Option<Arc<dyn Fn(String) + Send + Sync>>,
|
pub on_warning: Option<Arc<dyn Fn(String) + Send + Sync>>,
|
||||||
warning_sent: AtomicBool,
|
warning_sent: AtomicBool,
|
||||||
last_remaining_tokens: AtomicU64,
|
last_remaining_tokens: AtomicU64,
|
||||||
|
request_shape: Option<(String, agen::tool_server::ToolServerHandle)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CompactWorkerInterceptor {
|
impl CompactWorkerInterceptor {
|
||||||
@@ -397,9 +398,21 @@ impl CompactWorkerInterceptor {
|
|||||||
on_warning,
|
on_warning,
|
||||||
warning_sent: AtomicBool::new(false),
|
warning_sent: AtomicBool::new(false),
|
||||||
last_remaining_tokens: AtomicU64::new(max_input_tokens),
|
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> {
|
fn maybe_emit_warning(&self, remaining: u64) -> Option<Item> {
|
||||||
let warning_threshold = self.finish_warning_remaining_tokens;
|
let warning_threshold = self.finish_warning_remaining_tokens;
|
||||||
let reserve_threshold = self.final_reserve_tokens;
|
let reserve_threshold = self.final_reserve_tokens;
|
||||||
@@ -412,7 +425,12 @@ impl CompactWorkerInterceptor {
|
|||||||
let message = format!(
|
let message = format!(
|
||||||
"compact worker context budget is low ({remaining}/{} tokens remaining). \
|
"compact worker context budget is low ({remaining}/{} tokens remaining). \
|
||||||
Stop broad exploration now, read only if absolutely necessary, then call \
|
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
|
self.max_input_tokens
|
||||||
);
|
);
|
||||||
if let Some(cb) = self.on_warning.as_ref() {
|
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 context = context.items;
|
||||||
let records = self.usage_tracker.records();
|
let records = self.usage_tracker.records();
|
||||||
let estimate = agen::token_counter::total_tokens(context, &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!(
|
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
|
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
|
self.last_remaining_tokens
|
||||||
.store(remaining, Ordering::Release);
|
.store(remaining, Ordering::Release);
|
||||||
if let Some(item) = self.maybe_emit_warning(remaining) {
|
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);
|
self.usage_tracker.note_request(context.len() + 1);
|
||||||
return Ok(PreRequestAction::ContinueWith(vec![item]));
|
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]
|
#[tokio::test]
|
||||||
async fn compact_worker_interceptor_warns_before_hard_cap() {
|
async fn compact_worker_interceptor_warns_before_hard_cap() {
|
||||||
let tracker = Arc::new(UsageTracker::new());
|
let tracker = Arc::new(UsageTracker::new());
|
||||||
@@ -549,8 +667,8 @@ mod tests {
|
|||||||
let captured = warnings.clone();
|
let captured = warnings.clone();
|
||||||
let interceptor = CompactWorkerInterceptor::new(
|
let interceptor = CompactWorkerInterceptor::new(
|
||||||
tracker.clone(),
|
tracker.clone(),
|
||||||
150,
|
500,
|
||||||
60,
|
410,
|
||||||
20,
|
20,
|
||||||
Some(Arc::new(move |message| {
|
Some(Arc::new(move |message| {
|
||||||
captured.lock().unwrap().push(message);
|
captured.lock().unwrap().push(message);
|
||||||
|
|||||||
@@ -560,6 +560,7 @@ where
|
|||||||
attempt.instance_id, attempt.attempt_id
|
attempt.instance_id, attempt.attempt_id
|
||||||
)),
|
)),
|
||||||
max_turns: Some(12),
|
max_turns: Some(12),
|
||||||
|
compaction_budget: None,
|
||||||
engine_configurator: None,
|
engine_configurator: None,
|
||||||
features,
|
features,
|
||||||
required_tools: &[
|
required_tools: &[
|
||||||
|
|||||||
@@ -560,6 +560,7 @@ impl MemoryLifecycleTask {
|
|||||||
.extraction
|
.extraction
|
||||||
.worker_max_turns
|
.worker_max_turns
|
||||||
.or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
|
.or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS),
|
||||||
|
compaction_budget: None,
|
||||||
engine_configurator: None,
|
engine_configurator: None,
|
||||||
features,
|
features,
|
||||||
required_tools: &[
|
required_tools: &[
|
||||||
|
|||||||
@@ -376,6 +376,7 @@ impl SubjektivSurfaceLifecycleTask {
|
|||||||
input,
|
input,
|
||||||
cache_key: None,
|
cache_key: None,
|
||||||
max_turns: Some(EDITOR_MAX_TURNS),
|
max_turns: Some(EDITOR_MAX_TURNS),
|
||||||
|
compaction_budget: None,
|
||||||
engine_configurator: None,
|
engine_configurator: None,
|
||||||
features,
|
features,
|
||||||
required_tools: &["SubmitMemorySurface"],
|
required_tools: &["SubmitMemorySurface"],
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ pub(crate) struct InternalWorkerSpec {
|
|||||||
) + Send,
|
) + Send,
|
||||||
>,
|
>,
|
||||||
>,
|
>,
|
||||||
|
pub compaction_budget: Option<crate::compact::worker::CompactWorkerInterceptor>,
|
||||||
pub features: FeatureRegistryBuilder,
|
pub features: FeatureRegistryBuilder,
|
||||||
pub required_tools: &'static [&'static str],
|
pub required_tools: &'static [&'static str],
|
||||||
pub authority: InternalWorkerAuthority,
|
pub authority: InternalWorkerAuthority,
|
||||||
@@ -118,6 +119,7 @@ where
|
|||||||
cache_key,
|
cache_key,
|
||||||
max_turns,
|
max_turns,
|
||||||
engine_configurator,
|
engine_configurator,
|
||||||
|
compaction_budget,
|
||||||
features,
|
features,
|
||||||
required_tools,
|
required_tools,
|
||||||
authority,
|
authority,
|
||||||
@@ -171,6 +173,7 @@ where
|
|||||||
identity: identity.clone(),
|
identity: identity.clone(),
|
||||||
history_entries: 0,
|
history_entries: 0,
|
||||||
})?;
|
})?;
|
||||||
|
worker.install_compaction_budget(compaction_budget);
|
||||||
worker.disable_manifest_lifecycle_features();
|
worker.disable_manifest_lifecycle_features();
|
||||||
if let Some(session) = inherited_workdir_session {
|
if let Some(session) = inherited_workdir_session {
|
||||||
worker.bind_single_workdir_session(Some(session));
|
worker.bind_single_workdir_session(Some(session));
|
||||||
@@ -666,6 +669,7 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
cache_key,
|
cache_key,
|
||||||
max_turns,
|
max_turns,
|
||||||
engine_configurator,
|
engine_configurator,
|
||||||
|
compaction_budget,
|
||||||
features,
|
features,
|
||||||
required_tools,
|
required_tools,
|
||||||
authority,
|
authority,
|
||||||
@@ -701,6 +705,7 @@ pub(crate) async fn spawn_internal_worker_session(
|
|||||||
.map_err(|source| InternalWorkerSessionError::Build {
|
.map_err(|source| InternalWorkerSessionError::Build {
|
||||||
message: source.to_string(),
|
message: source.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
worker.install_compaction_budget(compaction_budget);
|
||||||
worker.disable_manifest_lifecycle_features();
|
worker.disable_manifest_lifecycle_features();
|
||||||
if let Some(session) = inherited_workdir_session {
|
if let Some(session) = inherited_workdir_session {
|
||||||
worker.bind_single_workdir_session(Some(session));
|
worker.bind_single_workdir_session(Some(session));
|
||||||
@@ -762,6 +767,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
|
|||||||
cache_key,
|
cache_key,
|
||||||
max_turns,
|
max_turns,
|
||||||
engine_configurator,
|
engine_configurator,
|
||||||
|
compaction_budget,
|
||||||
features,
|
features,
|
||||||
required_tools,
|
required_tools,
|
||||||
authority,
|
authority,
|
||||||
@@ -794,6 +800,7 @@ pub(crate) fn prepare_internal_worker_from_spec(
|
|||||||
.map_err(|source| InternalWorkerSessionError::Build {
|
.map_err(|source| InternalWorkerSessionError::Build {
|
||||||
message: source.to_string(),
|
message: source.to_string(),
|
||||||
})?;
|
})?;
|
||||||
|
worker.install_compaction_budget(compaction_budget);
|
||||||
worker.disable_manifest_lifecycle_features();
|
worker.disable_manifest_lifecycle_features();
|
||||||
if let Some(session) = inherited_workdir_session {
|
if let Some(session) = inherited_workdir_session {
|
||||||
worker.bind_single_workdir_session(Some(session));
|
worker.bind_single_workdir_session(Some(session));
|
||||||
@@ -1442,6 +1449,7 @@ permission = "write"
|
|||||||
input: "input".to_string(),
|
input: "input".to_string(),
|
||||||
cache_key: Some("internal-test".to_string()),
|
cache_key: Some("internal-test".to_string()),
|
||||||
max_turns: Some(1),
|
max_turns: Some(1),
|
||||||
|
compaction_budget: None,
|
||||||
engine_configurator: None,
|
engine_configurator: None,
|
||||||
features: FeatureRegistryBuilder::new(),
|
features: FeatureRegistryBuilder::new(),
|
||||||
required_tools,
|
required_tools,
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ async fn call_hook_before_deadline<E: HookEventKind>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) struct WorkerInterceptor {
|
pub(crate) struct WorkerInterceptor {
|
||||||
|
compaction_budget: Option<Arc<crate::compact::worker::CompactWorkerInterceptor>>,
|
||||||
registry: Arc<HookRegistry>,
|
registry: Arc<HookRegistry>,
|
||||||
compact_state: Option<Arc<CompactState>>,
|
compact_state: Option<Arc<CompactState>>,
|
||||||
/// Shared view of the cumulative UsageRecord timeline. Used with the
|
/// Shared view of the cumulative UsageRecord timeline. Used with the
|
||||||
@@ -157,6 +158,7 @@ impl WorkerInterceptor {
|
|||||||
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
|
compaction_budget: None,
|
||||||
registry,
|
registry,
|
||||||
compact_state,
|
compact_state,
|
||||||
usage_history,
|
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(
|
pub(crate) fn with_notification_committer(
|
||||||
mut self,
|
mut self,
|
||||||
committer: Option<Arc<dyn DurableNotificationCommitter>>,
|
committer: Option<Arc<dyn DurableNotificationCommitter>>,
|
||||||
@@ -552,6 +562,8 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
|||||||
&self,
|
&self,
|
||||||
context: PreLlmRequestContext<'_, SessionHistoryMetadata>,
|
context: PreLlmRequestContext<'_, SessionHistoryMetadata>,
|
||||||
) -> InterceptorResult<PreRequestAction> {
|
) -> InterceptorResult<PreRequestAction> {
|
||||||
|
let context_invocation = context.invocation;
|
||||||
|
let context_history = context.history;
|
||||||
let context = context.items;
|
let context = context.items;
|
||||||
let initial_tokens = self.estimated_tokens(context);
|
let initial_tokens = self.estimated_tokens(context);
|
||||||
if let Some(action) =
|
if let Some(action) =
|
||||||
@@ -616,7 +628,7 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
|||||||
.expect("pending hook system-item queue poisoned"),
|
.expect("pending hook system-item queue poisoned"),
|
||||||
);
|
);
|
||||||
self.attach_prompt_provenance(&mut system_items);
|
self.attach_prompt_provenance(&mut system_items);
|
||||||
let appended_items: Vec<Item> = system_items
|
let mut appended_items: Vec<Item> = system_items
|
||||||
.iter()
|
.iter()
|
||||||
.map(SystemItem::to_history_item)
|
.map(SystemItem::to_history_item)
|
||||||
.collect();
|
.collect();
|
||||||
@@ -652,10 +664,28 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(usage_tracker) = self.usage_tracker.as_ref() {
|
// Internal compaction safety must survive the normal Worker interceptor
|
||||||
usage_tracker.note_request(effective_context.len());
|
// 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);
|
return Ok(PreRequestAction::Continue);
|
||||||
}
|
}
|
||||||
Ok(match self.commit_system_items(&system_items) {
|
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() {
|
if let Some(reason) = denials.into_iter().next() {
|
||||||
return Ok(HookPreToolAction::Deny(reason).into_worker_action(summary.call_id.clone()));
|
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);
|
self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed);
|
||||||
Ok(PreToolAction::Continue)
|
Ok(PreToolAction::Continue)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -221,6 +221,7 @@ impl PreparedInternalJob {
|
|||||||
run_id: uuid::Uuid::now_v7(),
|
run_id: uuid::Uuid::now_v7(),
|
||||||
},
|
},
|
||||||
max_turns: engine_policy.max_turns.map(|n| n.get()),
|
max_turns: engine_policy.max_turns.map(|n| n.get()),
|
||||||
|
compaction_budget: None,
|
||||||
engine_configurator: Some(Box::new(move |engine| {
|
engine_configurator: Some(Box::new(move |engine| {
|
||||||
crate::apply_worker_manifest(engine, &engine_policy);
|
crate::apply_worker_manifest(engine, &engine_policy);
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -725,7 +725,9 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance
|
|||||||
}
|
}
|
||||||
WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
|
WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput,
|
||||||
WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
|
WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput,
|
||||||
WorkerHistoryProvenance::DerivedSummary => SessionEntryProvenance::DerivedSummary,
|
WorkerHistoryProvenance::CompactionSummary | WorkerHistoryProvenance::DerivedSummary => {
|
||||||
|
SessionEntryProvenance::DerivedSummary
|
||||||
|
}
|
||||||
WorkerHistoryProvenance::LegacyUnknown => SessionEntryProvenance::LegacyUnknown,
|
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]
|
#[test]
|
||||||
fn overview_contains_user_and_assistant_only() {
|
fn overview_contains_user_and_assistant_only() {
|
||||||
let view = SessionCapture::new(
|
let view = SessionCapture::new(
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin;
|
|||||||
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
|
pub type SessionHistoryDerivation = LoggedHistoryDerivation;
|
||||||
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
|
pub type WorkerSubjectSnapshot = LoggedWorkerSubject;
|
||||||
|
|
||||||
|
pub(crate) const COMPACTION_SUMMARY_HEADER: &str = "[Compacted context summary]\n\n";
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) fn test_logged_history_entry(item: impl Into<Item>) -> LoggedHistoryEntry {
|
pub(crate) fn test_logged_history_entry(item: impl Into<Item>) -> LoggedHistoryEntry {
|
||||||
LoggedHistoryEntry {
|
LoggedHistoryEntry {
|
||||||
@@ -108,8 +110,32 @@ pub(crate) fn restore_history_entries(
|
|||||||
let mut history = Vec::new();
|
let mut history = Vec::new();
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
match entry {
|
match entry {
|
||||||
LogEntry::AnnotatedSegmentStart { history: seed, .. } => {
|
LogEntry::AnnotatedSegmentStart {
|
||||||
|
history: seed,
|
||||||
|
compacted_from,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
history = seed.iter().map(from_logged).collect();
|
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, .. } => {
|
LogEntry::AnnotatedUserInput { history: input, .. } => {
|
||||||
history.extend(input.iter().map(from_logged))
|
history.extend(input.iter().map(from_logged))
|
||||||
@@ -144,6 +170,79 @@ mod tests {
|
|||||||
use protocol::Segment;
|
use protocol::Segment;
|
||||||
use session_store::LogEntry;
|
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]
|
#[test]
|
||||||
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
|
fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() {
|
||||||
let session_id = SessionId::now_v7();
|
let session_id = SessionId::now_v7();
|
||||||
|
|||||||
+205
-20
@@ -2801,6 +2801,7 @@ pub struct Worker<C: LlmClient, St: Store> {
|
|||||||
/// manifest-derived lifecycle Features before controller startup.
|
/// manifest-derived lifecycle Features before controller startup.
|
||||||
manifest_lifecycle_features_enabled: bool,
|
manifest_lifecycle_features_enabled: bool,
|
||||||
interceptor_installed: bool,
|
interceptor_installed: bool,
|
||||||
|
compaction_service_budget: Option<Arc<crate::compact::worker::CompactWorkerInterceptor>>,
|
||||||
/// Shared compaction state (present when threshold is configured).
|
/// Shared compaction state (present when threshold is configured).
|
||||||
compact_state: Option<Arc<CompactState>>,
|
compact_state: Option<Arc<CompactState>>,
|
||||||
/// Per-LLM-request Usage tracker. Always present after construction.
|
/// 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(),
|
feature_invocations: FeatureInvocationRegistry::default(),
|
||||||
manifest_lifecycle_features_enabled: true,
|
manifest_lifecycle_features_enabled: true,
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
|
compaction_service_budget: None,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
usage_tracker: Arc::new(UsageTracker::new()),
|
usage_tracker: Arc::new(UsageTracker::new()),
|
||||||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::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()
|
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 ---
|
// --- Hook registration ---
|
||||||
|
|
||||||
fn assert_hooks_open(&self) {
|
fn assert_hooks_open(&self) {
|
||||||
@@ -4316,6 +4336,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
self.log_writer.clone(),
|
self.log_writer.clone(),
|
||||||
self.pending_committed_history.clone(),
|
self.pending_committed_history.clone(),
|
||||||
)
|
)
|
||||||
|
.with_compaction_budget(self.compaction_service_budget.clone())
|
||||||
.with_notification_committer(self.notification_committer.clone())
|
.with_notification_committer(self.notification_committer.clone())
|
||||||
.with_usage_tracker(self.usage_tracker.clone())
|
.with_usage_tracker(self.usage_tracker.clone())
|
||||||
.with_metrics_tracker(self.metrics_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 history_entries = self.session.history().entries();
|
||||||
let retain_from = cut.index.min(history_entries.len());
|
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
|
let mut retained_items = retained_history_entries
|
||||||
.iter()
|
.iter()
|
||||||
.map(|entry| entry.item.clone())
|
.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());
|
project_compaction_attachments(&mut retained_items, default_workdir_alias.as_deref());
|
||||||
// The compactor explores summaries, text details, and ViewImage call arguments. It
|
// The compactor explores summaries, text details, and ViewImage call arguments. It
|
||||||
// never needs durable image bodies; relevant images are nominated with add_reference.
|
// 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);
|
project_compactor_capture_attachments(&mut entries_to_summarise);
|
||||||
let items_to_summarise = entries_to_summarise
|
let items_to_summarise = entries_to_summarise
|
||||||
.iter()
|
.iter()
|
||||||
@@ -6688,11 +6721,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
Vec::new()
|
Vec::new()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Input text fed to the compact worker. Includes the default
|
// The full Host summary is mandatory input, separate from the bounded
|
||||||
// references, current TaskStore snapshot, current TaskStore snapshot, and the (pruned) conversation text.
|
// prefix overview and current TaskStore snapshot.
|
||||||
let task_snapshot_text = self.task_feature.snapshot_text();
|
let task_snapshot_text = self.task_feature.snapshot_text();
|
||||||
let summary_input = build_summary_input(
|
let summary_input = build_summary_input(
|
||||||
&items_to_summarise,
|
&items_to_summarise,
|
||||||
|
initial_summary.as_deref(),
|
||||||
&default_refs,
|
&default_refs,
|
||||||
Some(task_snapshot_text.as_str()),
|
Some(task_snapshot_text.as_str()),
|
||||||
SummaryInputOptions {
|
SummaryInputOptions {
|
||||||
@@ -6804,12 +6838,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
input: summary_input.text.clone(),
|
input: summary_input.text.clone(),
|
||||||
cache_key: Some(self.segment_id().to_string()),
|
cache_key: Some(self.segment_id().to_string()),
|
||||||
max_turns: worker_max_turns,
|
max_turns: worker_max_turns,
|
||||||
|
compaction_budget: Some(interceptor),
|
||||||
engine_configurator: Some(Box::new(move |engine| {
|
engine_configurator: Some(Box::new(move |engine| {
|
||||||
let tracker = tracker_for_engine;
|
let tracker = tracker_for_engine;
|
||||||
engine.on_usage(move |event| {
|
engine.on_usage(move |event| {
|
||||||
tracker.record_usage(event);
|
tracker.record_usage(event);
|
||||||
});
|
});
|
||||||
engine.set_interceptor(interceptor);
|
|
||||||
})),
|
})),
|
||||||
features,
|
features,
|
||||||
required_tools,
|
required_tools,
|
||||||
@@ -6869,7 +6903,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
Some(
|
Some(
|
||||||
"You have not called `write_summary` yet. Deliver the structured \
|
"You have not called `write_summary` yet. Deliver the structured \
|
||||||
summary now (Completed Tasks / Active Task / Key Decisions / \
|
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 \
|
session needs with `mark_read_required` for UTF-8 text or \
|
||||||
`add_reference` for images, binary files, and on-demand reads."
|
`add_reference` for images, binary files, and on-demand reads."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
@@ -6901,11 +6936,8 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
.ok_or(WorkerError::CompactSummaryMissing)?;
|
.ok_or(WorkerError::CompactSummaryMissing)?;
|
||||||
let mut summary_tokens = estimate_text_tokens(summary_text.len());
|
let mut summary_tokens = estimate_text_tokens(summary_text.len());
|
||||||
if summary_max_tokens > 0 && summary_tokens > summary_max_tokens {
|
if summary_max_tokens > 0 && summary_tokens > summary_max_tokens {
|
||||||
let prompt = format!(
|
let prompt =
|
||||||
"Your `write_summary` output is too large (≈{summary_tokens} tokens; max \
|
summary_shrink_prompt(summary_tokens, summary_max_tokens, summary_target_tokens);
|
||||||
{summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \
|
|
||||||
same five sections but making it concise. Target ≈{summary_target_tokens} tokens."
|
|
||||||
);
|
|
||||||
handle
|
handle
|
||||||
.send(prompt)
|
.send(prompt)
|
||||||
.await
|
.await
|
||||||
@@ -7014,8 +7046,10 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
);
|
);
|
||||||
let mut compact_introduced_system_messages =
|
let mut compact_introduced_system_messages =
|
||||||
Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize);
|
Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize);
|
||||||
let summary_message =
|
let summary_message = Item::system_message(format!(
|
||||||
Item::system_message(format!("[Compacted context summary]\n\n{summary_text}"));
|
"{}{summary_text}",
|
||||||
|
crate::session_history::COMPACTION_SUMMARY_HEADER
|
||||||
|
));
|
||||||
compact_introduced_system_messages.push(summary_message.clone());
|
compact_introduced_system_messages.push(summary_message.clone());
|
||||||
compact_introduced_system_messages.extend(auto_read_messages.iter().cloned());
|
compact_introduced_system_messages.extend(auto_read_messages.iter().cloned());
|
||||||
if let Some(msg) = reference_message.as_ref() {
|
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
|
let compacted_history_entries = new_history
|
||||||
.iter()
|
.iter()
|
||||||
.cloned()
|
.cloned()
|
||||||
.map(|item| {
|
.enumerate()
|
||||||
if let Some((offset, original)) = original_entries[original_cursor..]
|
.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()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.find(|(_, original)| original.item == item)
|
.find(|(_, original)| original.item == item)
|
||||||
@@ -7489,6 +7534,7 @@ where
|
|||||||
feature_invocations: FeatureInvocationRegistry::default(),
|
feature_invocations: FeatureInvocationRegistry::default(),
|
||||||
manifest_lifecycle_features_enabled: true,
|
manifest_lifecycle_features_enabled: true,
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
|
compaction_service_budget: None,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
usage_tracker: Arc::new(UsageTracker::new()),
|
usage_tracker: Arc::new(UsageTracker::new()),
|
||||||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||||
@@ -7828,6 +7874,7 @@ where
|
|||||||
feature_invocations: FeatureInvocationRegistry::default(),
|
feature_invocations: FeatureInvocationRegistry::default(),
|
||||||
manifest_lifecycle_features_enabled: false,
|
manifest_lifecycle_features_enabled: false,
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
|
compaction_service_budget: None,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
usage_tracker: Arc::new(UsageTracker::new()),
|
usage_tracker: Arc::new(UsageTracker::new()),
|
||||||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||||
@@ -7957,6 +8004,7 @@ where
|
|||||||
feature_invocations: FeatureInvocationRegistry::default(),
|
feature_invocations: FeatureInvocationRegistry::default(),
|
||||||
manifest_lifecycle_features_enabled: true,
|
manifest_lifecycle_features_enabled: true,
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
|
compaction_service_budget: None,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
usage_tracker: Arc::new(UsageTracker::new()),
|
usage_tracker: Arc::new(UsageTracker::new()),
|
||||||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()),
|
||||||
@@ -8342,6 +8390,7 @@ where
|
|||||||
feature_invocations: FeatureInvocationRegistry::default(),
|
feature_invocations: FeatureInvocationRegistry::default(),
|
||||||
manifest_lifecycle_features_enabled: true,
|
manifest_lifecycle_features_enabled: true,
|
||||||
interceptor_installed: false,
|
interceptor_installed: false,
|
||||||
|
compaction_service_budget: None,
|
||||||
compact_state: None,
|
compact_state: None,
|
||||||
usage_tracker: Arc::new(UsageTracker::new()),
|
usage_tracker: Arc::new(UsageTracker::new()),
|
||||||
metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::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)]
|
#[derive(Debug, Clone, Copy)]
|
||||||
struct SummaryInputOptions {
|
struct SummaryInputOptions {
|
||||||
overview_target_tokens: u64,
|
overview_target_tokens: u64,
|
||||||
@@ -8756,11 +8860,12 @@ struct SummaryInputBuild {
|
|||||||
deadline_fallback_used: bool,
|
deadline_fallback_used: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the compact worker's input: default-reference instructions,
|
/// Build mandatory full Host-summary input plus a bounded prefix overview,
|
||||||
/// the list of recently-touched files, task snapshot,
|
/// recently-touched files and current tasks. Overview budgets never apply to
|
||||||
/// and a bounded overview rather than a prefix-wide transcript.
|
/// the summary; the complete request is checked by the compaction interceptor.
|
||||||
fn build_summary_input(
|
fn build_summary_input(
|
||||||
items: &[Item],
|
items: &[Item],
|
||||||
|
initial_summary: Option<&str>,
|
||||||
default_refs: &[PathBuf],
|
default_refs: &[PathBuf],
|
||||||
task_snapshot: Option<&str>,
|
task_snapshot: Option<&str>,
|
||||||
options: SummaryInputOptions,
|
options: SummaryInputOptions,
|
||||||
@@ -8786,10 +8891,20 @@ fn build_summary_input(
|
|||||||
out.push_str(&format!(
|
out.push_str(&format!(
|
||||||
"Summarise this session into a structured summary of about {} tokens and \
|
"Summarise this session into a structured summary of about {} tokens and \
|
||||||
nominate files the next session needs. The conversation below is a \
|
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",
|
current files when deciding auto-read/reference output.\n\n",
|
||||||
options.summary_target_tokens
|
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() {
|
if !default_refs.is_empty() {
|
||||||
out.push_str(
|
out.push_str(
|
||||||
"These files were touched recently in this session. Use `read_file` \
|
"These files were touched recently in this session. Use `read_file` \
|
||||||
@@ -8808,7 +8923,8 @@ fn build_summary_input(
|
|||||||
out.push_str(
|
out.push_str(
|
||||||
"## Current Session TaskStore\n\
|
"## Current Session TaskStore\n\
|
||||||
This is the full current task list. Use it as source material for the \
|
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",
|
from the compact worker.\n",
|
||||||
);
|
);
|
||||||
out.push_str(task_snapshot);
|
out.push_str(task_snapshot);
|
||||||
@@ -10103,6 +10219,7 @@ mod build_summary_prompt_tests {
|
|||||||
fn test_summary_input(items: &[Item]) -> String {
|
fn test_summary_input(items: &[Item]) -> String {
|
||||||
build_summary_input(
|
build_summary_input(
|
||||||
items,
|
items,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
SummaryInputOptions {
|
SummaryInputOptions {
|
||||||
@@ -10115,6 +10232,72 @@ mod build_summary_prompt_tests {
|
|||||||
.text
|
.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]
|
#[test]
|
||||||
fn retained_images_become_on_demand_related_files() {
|
fn retained_images_become_on_demand_related_files() {
|
||||||
let mut items = vec![
|
let mut items = vec![
|
||||||
@@ -10215,6 +10398,7 @@ mod build_summary_prompt_tests {
|
|||||||
let items = vec![Item::user_message("x".repeat(4_000))];
|
let items = vec![Item::user_message("x".repeat(4_000))];
|
||||||
let built = build_summary_input(
|
let built = build_summary_input(
|
||||||
&items,
|
&items,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
SummaryInputOptions {
|
SummaryInputOptions {
|
||||||
@@ -10234,6 +10418,7 @@ mod build_summary_prompt_tests {
|
|||||||
let items = vec![Item::user_message("x".repeat(4_000))];
|
let items = vec![Item::user_message("x".repeat(4_000))];
|
||||||
let built = build_summary_input(
|
let built = build_summary_input(
|
||||||
&items,
|
&items,
|
||||||
|
None,
|
||||||
&[],
|
&[],
|
||||||
None,
|
None,
|
||||||
SummaryInputOptions {
|
SummaryInputOptions {
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ struct MockClient {
|
|||||||
responses: Arc<Vec<Vec<LlmEvent>>>,
|
responses: Arc<Vec<Vec<LlmEvent>>>,
|
||||||
call_count: Arc<AtomicUsize>,
|
call_count: Arc<AtomicUsize>,
|
||||||
observer: Arc<Mutex<Option<CallObserver>>>,
|
observer: Arc<Mutex<Option<CallObserver>>>,
|
||||||
|
requests: Arc<Mutex<Vec<Request>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MockClient {
|
impl MockClient {
|
||||||
@@ -141,9 +142,14 @@ impl MockClient {
|
|||||||
responses: Arc::new(responses),
|
responses: Arc::new(responses),
|
||||||
call_count: Arc::new(AtomicUsize::new(0)),
|
call_count: Arc::new(AtomicUsize::new(0)),
|
||||||
observer: Arc::new(Mutex::new(None)),
|
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) {
|
fn set_observer(&self, observer: impl Fn(usize) + Send + Sync + 'static) {
|
||||||
*self.observer.lock().unwrap() = Some(Arc::new(observer));
|
*self.observer.lock().unwrap() = Some(Arc::new(observer));
|
||||||
}
|
}
|
||||||
@@ -157,9 +163,10 @@ impl LlmClient for MockClient {
|
|||||||
|
|
||||||
async fn stream(
|
async fn stream(
|
||||||
&self,
|
&self,
|
||||||
_request: Request,
|
request: Request,
|
||||||
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
|
) -> 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);
|
let count = self.call_count.fetch_add(1, Ordering::SeqCst);
|
||||||
if let Some(observer) = self.observer.lock().unwrap().clone() {
|
if let Some(observer) = self.observer.lock().unwrap().clone() {
|
||||||
observer(count);
|
observer(count);
|
||||||
@@ -2065,3 +2072,721 @@ async fn controller_compact_method_publishes_progress_and_clear() {
|
|||||||
let command = protocol::WorkerCommandEnvelope::new(2);
|
let command = protocol::WorkerCommandEnvelope::new(2);
|
||||||
let _ = handle.send(Method::Shutdown { command }).await;
|
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(
|
tx.execute_batch(
|
||||||
"CREATE TEMP TABLE repository_key_cutover (
|
"CREATE TEMP TABLE repository_key_cutover (
|
||||||
workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
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));",
|
PRIMARY KEY(workspace_id,kind,resource_id,legacy_counter));",
|
||||||
)?;
|
)?;
|
||||||
for (table, kind, id, fingerprint) in [
|
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 {
|
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 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
|
let matches = statement
|
||||||
.query_map(params![workspace, kind, resource, legacy_counter, created], |r| {
|
.query_map(
|
||||||
r.get::<_, String>(0)
|
params![workspace, kind, resource, legacy_counter, created],
|
||||||
})?
|
|r| r.get::<_, String>(0),
|
||||||
|
)?
|
||||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||||
if matches.len() != 1 || matches[0].is_empty() {
|
if matches.len() != 1 || matches[0].is_empty() {
|
||||||
return Err(Error::Store("Repository key cutover requires one unambiguous mutation receipt per stored key".into()));
|
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(
|
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![
|
params![
|
||||||
workspace,
|
workspace,
|
||||||
kind,
|
kind,
|
||||||
@@ -84,11 +90,26 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
|
|||||||
legacy_counter,
|
legacy_counter,
|
||||||
matches[0],
|
matches[0],
|
||||||
fingerprint,
|
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
|
// Current rows must agree with their pointed-to historical key, not merely
|
||||||
// resolve to a nonempty receipt. FK checks alone cannot prove this identity.
|
// resolve to a nonempty receipt. FK checks alone cannot prove this identity.
|
||||||
let inconsistent: bool = tx.query_row("SELECT EXISTS (
|
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<_>>>()?
|
.collect::<rusqlite::Result<Vec<_>>>()?
|
||||||
};
|
};
|
||||||
for (workspace, id, legacy_counter, purpose, algorithm, nonce, ciphertext, created, operation) in
|
for (
|
||||||
secrets
|
workspace,
|
||||||
|
id,
|
||||||
|
legacy_counter,
|
||||||
|
purpose,
|
||||||
|
algorithm,
|
||||||
|
nonce,
|
||||||
|
ciphertext,
|
||||||
|
created,
|
||||||
|
operation,
|
||||||
|
) in secrets
|
||||||
{
|
{
|
||||||
if algorithm != "aes-256-gcm-v1" {
|
if algorithm != "aes-256-gcm-v1" {
|
||||||
return Err(Error::Store(
|
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
|
SELECT o.workspace_id,o.operation_id FROM workdir_create_operations_v86 o
|
||||||
WHERE o.state='succeeded'
|
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 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));
|
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
|
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,
|
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
|
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
|
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
|
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_%'))
|
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
|
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
|
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
|
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;
|
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END;
|
||||||
INSERT INTO workdir_create_operations
|
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
|
CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END
|
||||||
FROM workdir_create_operations_v86 o
|
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 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_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 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_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
|
INSERT INTO workdir_create_credential_candidates
|
||||||
SELECT c.workspace_id,c.operation_id,c.ordinal,c.role,c.credential_id,m.fingerprint
|
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
|
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
|
||||||
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
|
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);
|
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;")?;
|
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))?;
|
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(&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_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_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);")?;
|
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)
|
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
|
// Preserve every SQLite field and NULL verbatim as typed JSON. Frozen archives
|
||||||
// are never read by credential lookup, lease construction, or a live CAS.
|
// are never read by credential lookup, lease construction, or a live CAS.
|
||||||
fn frozen_repository_json_rows(
|
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();
|
let end = start + frozen[start..].find(end).unwrap();
|
||||||
conn.execute_batch(&frozen[start..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');
|
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','created',NULL);
|
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','created');
|
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','created');
|
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)
|
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_candidates VALUES('space','create-workdir',0,'primary','key',7);
|
||||||
INSERT INTO workdir_create_credential_revision_retentions VALUES('space','create-workdir',0,'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
|
CREATE TRIGGER workdir_create_insert_blocked_by_runtime_removal
|
||||||
@@ -59,11 +59,300 @@ fn legacy_repository_keys(path: &std::path::Path) -> Connection {
|
|||||||
&mut ciphertext,
|
&mut ciphertext,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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
|
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]
|
#[test]
|
||||||
fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
|
fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
@@ -150,12 +439,12 @@ fn repository_key_cutover_reseals_secrets_and_preserves_retention_and_audit() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert!(
|
assert!(
|
||||||
conn.execute(
|
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()
|
.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!(
|
assert!(
|
||||||
!conn
|
!conn
|
||||||
.prepare("PRAGMA foreign_key_check")
|
.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 dir = tempfile::tempdir().unwrap();
|
||||||
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
let conn = legacy_repository_keys(&dir.path().join("server.db"));
|
||||||
std::fs::write(dir.path().join("repository-secrets.master-key"), [42; 32]).unwrap();
|
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');
|
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,'updated');
|
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,'created','updated');
|
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','created');
|
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','updated');
|
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','updated');
|
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,'deleted');
|
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','deleted');
|
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();
|
UPDATE workdir_create_operations SET host_trust_id='host',host_trust_revision=3;").unwrap();
|
||||||
conn.execute_batch(&format!(
|
conn.execute_batch(&format!(
|
||||||
"PRAGMA foreign_keys={foreign_keys}; PRAGMA legacy_alter_table={legacy_alter};"
|
"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";
|
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("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;
|
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_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','deleted');
|
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();
|
DELETE FROM repository_ssh_credentials;").unwrap();
|
||||||
let original =
|
let original =
|
||||||
frozen_repository_json_rows(&conn, "SELECT * FROM workdir_create_operations", &[])
|
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",
|
"credential",
|
||||||
"key",
|
"key",
|
||||||
1,
|
1,
|
||||||
"2026-01-01",
|
"2026-01-01T00:00:00.000Z",
|
||||||
"credential_created",
|
"credential_created",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -675,7 +964,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
|||||||
"credential",
|
"credential",
|
||||||
"key",
|
"key",
|
||||||
2,
|
2,
|
||||||
"2026-01-02",
|
"2026-01-02T00:00:00.000Z",
|
||||||
"credential_rotated",
|
"credential_rotated",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -684,7 +973,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
|||||||
"host_trust",
|
"host_trust",
|
||||||
"host",
|
"host",
|
||||||
1,
|
1,
|
||||||
"2026-01-01",
|
"2026-01-01T00:00:00.000Z",
|
||||||
"host_trust_created",
|
"host_trust_created",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@@ -693,7 +982,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
|||||||
"host_trust",
|
"host_trust",
|
||||||
"host",
|
"host",
|
||||||
2,
|
2,
|
||||||
"2026-01-02",
|
"2026-01-02T00:00:00.000Z",
|
||||||
"host_trust_rotated",
|
"host_trust_rotated",
|
||||||
),
|
),
|
||||||
] {
|
] {
|
||||||
@@ -708,11 +997,11 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.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());
|
let encryption_key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
|
||||||
for (legacy_counter, fingerprint, private, timestamp) in [
|
for (legacy_counter, fingerprint, private, timestamp) in [
|
||||||
(1u64, &old_fp, &old_private, "2026-01-01"),
|
(1u64, &old_fp, &old_private, "2026-01-01T00:00:00.000Z"),
|
||||||
(2, &next_fp, &next_private, "2026-01-02"),
|
(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();
|
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];
|
let nonce = [legacy_counter as u8; 12];
|
||||||
@@ -727,10 +1016,10 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
|
|||||||
.unwrap();
|
.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 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 [
|
for (legacy_counter, hostname, timestamp) in [
|
||||||
(1, "old.example.test", "2026-01-01"),
|
(1, "old.example.test", "2026-01-01T00:00:00.000Z"),
|
||||||
(2, "new.example.test", "2026-01-02"),
|
(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();
|
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
|
// 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.
|
// delete receipt must remain history, not acquire proof from this new key.
|
||||||
conn.execute_batch("DELETE FROM workdir_create_operations;
|
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_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-01');").unwrap();
|
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();
|
migrate_repository_keys_v86_to_v87(&conn).unwrap();
|
||||||
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
// The service consumes current receipt columns; unrelated schemas are not this fixture.
|
||||||
rename_saved_secret_receipt_counters(&conn).unwrap();
|
rename_saved_secret_receipt_counters(&conn).unwrap();
|
||||||
@@ -966,8 +1255,8 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
|
|||||||
}
|
}
|
||||||
"inconsistent_retention" => {
|
"inconsistent_retention" => {
|
||||||
// A different valid retained key passes the ordinal-only candidate FK.
|
// 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');
|
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','later');
|
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();
|
UPDATE workdir_create_credential_revision_retentions SET credential_revision=8;").unwrap();
|
||||||
}
|
}
|
||||||
"missing_audit_receipt" => {
|
"missing_audit_receipt" => {
|
||||||
@@ -978,12 +1267,12 @@ fn repository_key_cutover_failure_preserves_schema_ciphertext_and_connection_pra
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
"missing_host_receipt" => {
|
"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');
|
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','created');").unwrap();
|
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" => {
|
"foreign_key_violation" => {
|
||||||
conn.execute_batch("PRAGMA foreign_keys=OFF;
|
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();
|
PRAGMA foreign_keys=ON;").unwrap();
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
|
|||||||
@@ -817,17 +817,12 @@ mod current_authority {
|
|||||||
|
|
||||||
fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) {
|
fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) {
|
||||||
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
let store = SqliteWorkspaceStore::in_memory().unwrap();
|
||||||
let workspace_key =
|
|
||||||
worker_runtime::auth::RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
|
||||||
let runtime_key =
|
let runtime_key =
|
||||||
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-key").unwrap();
|
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| {
|
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');
|
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');")?;
|
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 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(())
|
Ok(())
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
let request = WorkspaceRuntimeBinding {
|
let request = WorkspaceRuntimeBinding {
|
||||||
@@ -838,11 +833,7 @@ mod current_authority {
|
|||||||
public_key: runtime_key.public_key,
|
public_key: runtime_key.public_key,
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
binding_id: String::new(),
|
binding_id: String::new(),
|
||||||
state: WorkspaceRuntimeBindingState::Configured,
|
|
||||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
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(),
|
created_at: "created".into(),
|
||||||
updated_at: "updated".into(),
|
updated_at: "updated".into(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
@@ -853,36 +844,10 @@ mod current_authority {
|
|||||||
(store, binding)
|
(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]
|
#[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 (store, binding) = binding_store();
|
||||||
let old_proof = evidence(&binding);
|
assert!(store.workspace_runtime_binding_matches(&binding).unwrap());
|
||||||
store
|
|
||||||
.record_workspace_runtime_verification_attempt(&old_proof)
|
|
||||||
.unwrap();
|
|
||||||
store
|
|
||||||
.complete_workspace_runtime_verification(&old_proof)
|
|
||||||
.unwrap();
|
|
||||||
let (_, revoked) = store
|
let (_, revoked) = store
|
||||||
.revoke_workspace_runtime_binding_key(
|
.revoke_workspace_runtime_binding_key(
|
||||||
"space",
|
"space",
|
||||||
@@ -893,16 +858,22 @@ mod current_authority {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_ne!(binding.binding_id, revoked.binding_id);
|
assert_ne!(binding.binding_id, revoked.binding_id);
|
||||||
let mut replacement = binding.clone();
|
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
|
||||||
replacement.state = WorkspaceRuntimeBindingState::Configured;
|
assert!(!store.workspace_runtime_binding_matches(&revoked).unwrap());
|
||||||
let (_, reactivated) = store
|
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();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
binding.public_key_fingerprint,
|
binding.public_key_fingerprint,
|
||||||
reactivated.public_key_fingerprint
|
reactivated.public_key_fingerprint
|
||||||
);
|
);
|
||||||
assert_ne!(binding.binding_id, reactivated.binding_id);
|
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!(
|
assert!(matches!(
|
||||||
store.put_workspace_runtime_binding_key(
|
store.put_workspace_runtime_binding_key(
|
||||||
reactivated.clone(),
|
reactivated.clone(),
|
||||||
@@ -921,42 +892,27 @@ mod current_authority {
|
|||||||
),
|
),
|
||||||
Err(Error::RuntimeBindingIdConflict { .. })
|
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]
|
#[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 (store, binding) = binding_store();
|
||||||
let proof = evidence(&binding);
|
let renamed = store
|
||||||
store
|
.update_workspace_runtime_binding_metadata(
|
||||||
.record_workspace_runtime_verification_attempt(&proof)
|
"space",
|
||||||
.unwrap();
|
"remote",
|
||||||
store
|
"Renamed",
|
||||||
.complete_workspace_runtime_verification(&proof)
|
&binding.base_url,
|
||||||
|
"renamed-at",
|
||||||
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
assert_eq!(binding.binding_id, renamed.binding_id);
|
||||||
|
assert!(!store.workspace_runtime_binding_matches(&binding).unwrap());
|
||||||
let moved = store
|
let moved = store
|
||||||
.update_workspace_runtime_binding_metadata(
|
.update_workspace_runtime_binding_metadata(
|
||||||
"space",
|
"space",
|
||||||
"remote",
|
"remote",
|
||||||
"Remote",
|
"Renamed",
|
||||||
"https://other.test",
|
"https://other.test",
|
||||||
"changed",
|
"changed",
|
||||||
)
|
)
|
||||||
@@ -965,35 +921,166 @@ mod current_authority {
|
|||||||
.update_workspace_runtime_binding_metadata(
|
.update_workspace_runtime_binding_metadata(
|
||||||
"space",
|
"space",
|
||||||
"remote",
|
"remote",
|
||||||
"Remote",
|
"Renamed",
|
||||||
"https://remote.test",
|
"https://remote.test",
|
||||||
"changed-back",
|
"changed-back",
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_ne!(moved.binding_id, binding.binding_id);
|
assert_ne!(moved.binding_id, binding.binding_id);
|
||||||
assert_ne!(returned.binding_id, binding.binding_id);
|
assert_ne!(returned.binding_id, binding.binding_id);
|
||||||
assert!(
|
assert!(!store.workspace_runtime_binding_matches(&renamed).unwrap());
|
||||||
store
|
store.with_conn(|conn| { conn.execute("UPDATE workspace_signing_identities SET public_key_fingerprint='sha256:replacement' WHERE workspace_id='space'", [])?; Ok(()) }).unwrap();
|
||||||
.record_workspace_runtime_verification_attempt(&proof)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
let current = evidence(&returned);
|
|
||||||
store
|
|
||||||
.record_workspace_runtime_verification_attempt(¤t)
|
|
||||||
.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(¤t)
|
|
||||||
.is_err()
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
.get_workspace_runtime_binding("space", "remote")
|
.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()
|
.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()
|
.unwrap()
|
||||||
.state,
|
|
||||||
WorkspaceRuntimeBindingState::Configured
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,7 @@ use crate::Error;
|
|||||||
use crate::resource_broker::BackendResourceBroker;
|
use crate::resource_broker::BackendResourceBroker;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use crate::resource_broker::BackendResourceTarget;
|
use crate::resource_broker::BackendResourceTarget;
|
||||||
use crate::store::{
|
use crate::store::{ControlPlaneStore, WorkspaceRuntimeAuthenticationMode};
|
||||||
ControlPlaneStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBindingState,
|
|
||||||
};
|
|
||||||
use crate::workspace_signing_identity::WorkspaceSigningIdentityService;
|
use crate::workspace_signing_identity::WorkspaceSigningIdentityService;
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use protocol::Segment;
|
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,
|
SSH_HOST_KEY_PROBE_OPERATION, SSH_HOST_KEY_PROBE_PATH, SshHostKeyProbeRequest,
|
||||||
SshHostKeyProbeResponse,
|
SshHostKeyProbeResponse,
|
||||||
};
|
};
|
||||||
use worker_runtime::workspace_issuer::{
|
use worker_runtime::workspace_issuer::{WorkspaceCapabilityClaims, workspace_request_body_digest};
|
||||||
WorkspaceCapabilityClaims, WorkspaceRuntimeVerificationAcknowledgement,
|
|
||||||
WorkspaceRuntimeVerificationChallenge, WorkspaceRuntimeVerificationReceipt,
|
|
||||||
WorkspaceRuntimeVerificationResponse, workspace_request_body_digest,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
|
pub const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
|
||||||
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
|
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_hosts(&self, limit: usize) -> RuntimeList<InternalHostSummary>;
|
||||||
|
|
||||||
fn list_workers(&self, limit: usize) -> RuntimeList<InternalWorkerSummary>;
|
fn list_workers(&self, limit: usize) -> RuntimeList<InternalWorkerSummary>;
|
||||||
@@ -2277,48 +2245,6 @@ impl RuntimeRegistry {
|
|||||||
runtime.ping()
|
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>> {
|
fn runtimes_snapshot(&self) -> Vec<Arc<dyn WorkspaceWorkerRuntime>> {
|
||||||
self.runtimes
|
self.runtimes
|
||||||
.read()
|
.read()
|
||||||
@@ -3518,7 +3444,9 @@ pub struct WorkspaceRuntimeAuthorization {
|
|||||||
store: Arc<dyn ControlPlaneStore>,
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
signing_identities: WorkspaceSigningIdentityService,
|
signing_identities: WorkspaceSigningIdentityService,
|
||||||
backend_url: String,
|
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 {
|
impl std::fmt::Debug for WorkspaceRuntimeAuthorization {
|
||||||
@@ -3535,19 +3463,13 @@ impl WorkspaceRuntimeAuthorization {
|
|||||||
store: Arc<dyn ControlPlaneStore>,
|
store: Arc<dyn ControlPlaneStore>,
|
||||||
signing_identities: WorkspaceSigningIdentityService,
|
signing_identities: WorkspaceSigningIdentityService,
|
||||||
backend_url: impl Into<String>,
|
backend_url: impl Into<String>,
|
||||||
binding: Option<crate::store::WorkspaceRuntimeBinding>,
|
binding: crate::store::WorkspaceRuntimeBinding,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
store,
|
store,
|
||||||
signing_identities,
|
signing_identities,
|
||||||
backend_url: backend_url.into(),
|
backend_url: backend_url.into(),
|
||||||
binding: Arc::new(RwLock::new(binding)),
|
binding,
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn activate(&self, binding: crate::store::WorkspaceRuntimeBinding) {
|
|
||||||
if let Ok(mut current) = self.binding.write() {
|
|
||||||
*current = Some(binding);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3559,30 +3481,14 @@ impl WorkspaceRuntimeAuthorization {
|
|||||||
worker_id: Option<&str>,
|
worker_id: Option<&str>,
|
||||||
body: &[u8],
|
body: &[u8],
|
||||||
) -> Result<String, RuntimeDiagnostic> {
|
) -> Result<String, RuntimeDiagnostic> {
|
||||||
let binding = self
|
let binding = &self.binding;
|
||||||
.binding
|
// A removed/replaced endpoint must not be used by an old client. This
|
||||||
.read()
|
// compares connection configuration, not cached authentication success.
|
||||||
.map_err(|_| {
|
if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
|
||||||
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
|
|
||||||
|| binding.revoked_at.is_some()
|
|| binding.revoked_at.is_some()
|
||||||
|| !self
|
|| !self
|
||||||
.store
|
.store
|
||||||
.workspace_runtime_binding_matches(&binding)
|
.workspace_runtime_binding_matches(binding)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
diagnostic(
|
diagnostic(
|
||||||
"workspace_runtime_authorization_unavailable",
|
"workspace_runtime_authorization_unavailable",
|
||||||
@@ -3594,7 +3500,7 @@ impl WorkspaceRuntimeAuthorization {
|
|||||||
return Err(diagnostic(
|
return Err(diagnostic(
|
||||||
"workspace_runtime_authorization_stale",
|
"workspace_runtime_authorization_stale",
|
||||||
HostDiagnosticSeverity::Error,
|
HostDiagnosticSeverity::Error,
|
||||||
"Workspace Runtime binding changed or was revoked".to_string(),
|
"Workspace Runtime connection changed or was revoked".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let identity = self
|
let identity = self
|
||||||
@@ -3607,63 +3513,26 @@ impl WorkspaceRuntimeAuthorization {
|
|||||||
error.to_string(),
|
error.to_string(),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let workspace_key_id = binding.workspace_key_id.as_deref().ok_or_else(|| {
|
if identity.state != "active" {
|
||||||
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(),
|
|
||||||
)
|
|
||||||
})?
|
|
||||||
{
|
|
||||||
return Err(diagnostic(
|
return Err(diagnostic(
|
||||||
"workspace_runtime_authorization_stale",
|
"workspace_signing_identity_unavailable",
|
||||||
HostDiagnosticSeverity::Error,
|
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 now = Utc::now().timestamp();
|
||||||
let claims = WorkspaceCapabilityClaims {
|
let claims = WorkspaceCapabilityClaims {
|
||||||
issuer: self.backend_url.clone(),
|
issuer: self.backend_url.clone(),
|
||||||
issuer_workspace_id: binding.workspace_id.clone(),
|
issuer_workspace_id: binding.workspace_id.clone(),
|
||||||
issuer_key_id: identity.key_id,
|
issuer_key_id: identity.key_id,
|
||||||
issuer_public_key_fingerprint: workspace_public_key_fingerprint.to_string(),
|
issuer_public_key_fingerprint: fingerprint,
|
||||||
trust_id: workspace_trust_id.to_string(),
|
|
||||||
binding_id: binding.binding_id.clone(),
|
|
||||||
runtime_id: binding.runtime_id.clone(),
|
runtime_id: binding.runtime_id.clone(),
|
||||||
worker_id: worker_id.map(str::to_string),
|
worker_id: worker_id.map(str::to_string),
|
||||||
operation: operation.to_string(),
|
operation: operation.to_string(),
|
||||||
@@ -3674,6 +3543,8 @@ impl WorkspaceRuntimeAuthorization {
|
|||||||
exp: now.saturating_add(60),
|
exp: now.saturating_add(60),
|
||||||
jti: uuid::Uuid::now_v7().to_string(),
|
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
|
self.signing_identities
|
||||||
.issue_workspace_capability(&binding.workspace_id, &claims)
|
.issue_workspace_capability(&binding.workspace_id, &claims)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
@@ -4251,49 +4122,6 @@ impl RemoteWorkerRuntime {
|
|||||||
format!("{base}/v1/workers/{worker_id}/protocol/ws")
|
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>
|
fn get_json<T>(&self, path: &str) -> Result<T, RuntimeDiagnostic>
|
||||||
where
|
where
|
||||||
T: DeserializeOwned + Send + 'static,
|
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> {
|
fn list_hosts(&self, limit: usize) -> RuntimeList<InternalHostSummary> {
|
||||||
if limit == 0 {
|
if limit == 0 {
|
||||||
return RuntimeList::new(Vec::new(), Vec::new());
|
return RuntimeList::new(Vec::new(), Vec::new());
|
||||||
@@ -6536,6 +6334,7 @@ pub fn placeholder_spawn_response(host_id: impl Into<String>) -> WorkerSpawnResu
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
mod restore_auth_tests;
|
mod restore_auth_tests;
|
||||||
|
mod workspace_auth_tests;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|||||||
@@ -9,36 +9,18 @@ use worker_runtime::{
|
|||||||
auth::RuntimeIdentityMaterial,
|
auth::RuntimeIdentityMaterial,
|
||||||
http_server::{WorkspaceRuntimeHttpAuth, runtime_http_router_with_workspace_auth},
|
http_server::{WorkspaceRuntimeHttpAuth, runtime_http_router_with_workspace_auth},
|
||||||
workspace_issuer::{
|
workspace_issuer::{
|
||||||
InMemoryWorkspaceClaimReplayProtection, InMemoryWorkspaceRuntimeVerificationAuthority,
|
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
|
||||||
RuntimeVerificationSigner, WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord,
|
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState, issue_workspace_capability_token,
|
||||||
WorkspaceIssuerTrustState, WorkspaceRuntimeVerificationAuthority,
|
|
||||||
WorkspaceRuntimeVerificationRecord, issue_workspace_capability_token,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_binding() {
|
async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_binding() {
|
||||||
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
|
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 now = Utc::now().timestamp();
|
||||||
let workspace_public_key_fingerprint =
|
let workspace_public_key_fingerprint =
|
||||||
crate::workspace_signing_identity::public_key_fingerprint(&workspace_identity.public_key)
|
crate::workspace_signing_identity::public_key_fingerprint(&workspace_identity.public_key)
|
||||||
.unwrap();
|
.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 =
|
let public_key =
|
||||||
worker_runtime::auth::decode_public_key(&workspace_identity.public_key).unwrap();
|
worker_runtime::auth::decode_public_key(&workspace_identity.public_key).unwrap();
|
||||||
let verifier = WorkspaceCapabilityVerifier::new(
|
let verifier = WorkspaceCapabilityVerifier::new(
|
||||||
@@ -69,8 +51,7 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
|||||||
None,
|
None,
|
||||||
WorkspaceRuntimeHttpAuth {
|
WorkspaceRuntimeHttpAuth {
|
||||||
verifier,
|
verifier,
|
||||||
signer,
|
runtime_id: "runtime-test".into(),
|
||||||
verifications,
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
let worker_id = EmbeddedWorkerId::from_legacy_u64(1).to_string();
|
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-worker",
|
||||||
"wrong-body",
|
"wrong-body",
|
||||||
"wrong-key-fingerprint",
|
"wrong-key-fingerprint",
|
||||||
"wrong-binding-id",
|
|
||||||
"wrong-trust-id",
|
|
||||||
] {
|
] {
|
||||||
let claims = WorkspaceCapabilityClaims {
|
let claims = WorkspaceCapabilityClaims {
|
||||||
issuer: "https://backend.test".into(),
|
issuer: "https://backend.test".into(),
|
||||||
@@ -111,16 +90,6 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
|||||||
} else {
|
} else {
|
||||||
workspace_public_key_fingerprint.clone()
|
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(),
|
runtime_id: "runtime-test".into(),
|
||||||
worker_id: if case == "wrong-worker" {
|
worker_id: if case == "wrong-worker" {
|
||||||
Some(EmbeddedWorkerId::from_legacy_u64(2).to_string())
|
Some(EmbeddedWorkerId::from_legacy_u64(2).to_string())
|
||||||
@@ -176,27 +145,16 @@ async fn restore_capabilities_authorize_runtime_routes_without_relaxing_request_
|
|||||||
} else {
|
} else {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
status,
|
status,
|
||||||
if case == "wrong-binding-id" {
|
StatusCode::UNAUTHORIZED,
|
||||||
StatusCode::FORBIDDEN
|
|
||||||
} else {
|
|
||||||
StatusCode::UNAUTHORIZED
|
|
||||||
},
|
|
||||||
"{path} {case}: {}",
|
"{path} {case}: {}",
|
||||||
error.error.message
|
error.error.message
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(error.error.code, "unauthorized");
|
||||||
error.error.code,
|
|
||||||
if case == "wrong-binding-id" {
|
|
||||||
"workspace_runtime_verification_stale"
|
|
||||||
} else {
|
|
||||||
"unauthorized"
|
|
||||||
}
|
|
||||||
);
|
|
||||||
let expected = match case {
|
let expected = match case {
|
||||||
"wrong-operation" => Some("does not authorize this operation"),
|
"wrong-operation" => Some("does not authorize this operation"),
|
||||||
"wrong-worker" => Some("targets another Worker"),
|
"wrong-worker" => Some("targets another Worker"),
|
||||||
"wrong-body" => Some("does not bind this request body"),
|
"wrong-body" => Some("does not bind this request body"),
|
||||||
"wrong-key-fingerprint" | "wrong-binding-id" | "wrong-trust-id" => None,
|
"wrong-key-fingerprint" => None,
|
||||||
_ => unreachable!(),
|
_ => unreachable!(),
|
||||||
};
|
};
|
||||||
if let Some(expected) = expected {
|
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();
|
||||||
|
}
|
||||||
@@ -442,49 +442,43 @@ CREATE TABLE workspace_runtime_bindings (
|
|||||||
public_key TEXT NOT NULL,
|
public_key TEXT NOT NULL,
|
||||||
public_key_fingerprint TEXT NOT NULL,
|
public_key_fingerprint TEXT NOT NULL,
|
||||||
binding_id TEXT NOT NULL CHECK (length(binding_id) > 0),
|
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')),
|
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,
|
created_at TEXT NOT NULL,
|
||||||
updated_at TEXT NOT NULL,
|
updated_at TEXT NOT NULL,
|
||||||
revoked_at TEXT,
|
revoked_at TEXT,
|
||||||
PRIMARY KEY (workspace_id, runtime_id),
|
PRIMARY KEY (workspace_id, runtime_id),
|
||||||
UNIQUE (workspace_id, public_key_fingerprint),
|
UNIQUE (workspace_id, public_key_fingerprint),
|
||||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
||||||
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)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
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,
|
workspace_id TEXT NOT NULL,
|
||||||
runtime_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_key_id TEXT NOT NULL,
|
||||||
workspace_public_key_fingerprint TEXT NOT NULL CHECK(length(workspace_public_key_fingerprint) > 0),
|
workspace_public_key_fingerprint TEXT NOT NULL,
|
||||||
workspace_trust_id TEXT NOT NULL CHECK(length(workspace_trust_id) > 0),
|
workspace_trust_id TEXT NOT NULL,
|
||||||
runtime_public_key_fingerprint TEXT NOT NULL,
|
runtime_public_key_fingerprint TEXT NOT NULL,
|
||||||
challenge_id 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,
|
last_outcome TEXT NOT NULL,
|
||||||
verified_at TEXT,
|
verified_at TEXT,
|
||||||
checked_at TEXT NOT NULL,
|
checked_at TEXT NOT NULL,
|
||||||
PRIMARY KEY(workspace_id, runtime_id),
|
PRIMARY KEY (workspace_id, runtime_id),
|
||||||
FOREIGN KEY(workspace_id, runtime_id)
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
|
||||||
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))
|
|
||||||
);
|
);
|
||||||
CREATE INDEX workspace_runtime_verifications_state_idx
|
|
||||||
ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);
|
|
||||||
CREATE TABLE workspace_runtime_binding_audit (
|
CREATE TABLE workspace_runtime_binding_audit (
|
||||||
workspace_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL,
|
||||||
runtime_id TEXT NOT NULL,
|
runtime_id TEXT NOT NULL,
|
||||||
@@ -1699,7 +1693,7 @@ CREATE TABLE IF NOT EXISTS ticket_assignment_work_releases (
|
|||||||
FOREIGN KEY (workspace_id, assignment_id)
|
FOREIGN KEY (workspace_id, assignment_id)
|
||||||
REFERENCES ticket_worker_assignments(workspace_id, assignment_id) ON DELETE CASCADE
|
REFERENCES ticket_worker_assignments(workspace_id, assignment_id) ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
-- Ticket case assignment work constraints (schema 90).
|
-- Ticket case assignment work constraints (schema 91).
|
||||||
DROP VIEW IF EXISTS ticket_active_worker_assignments;
|
DROP VIEW IF EXISTS ticket_active_worker_assignments;
|
||||||
CREATE VIEW ticket_active_worker_assignments AS
|
CREATE VIEW ticket_active_worker_assignments AS
|
||||||
SELECT current.* FROM ticket_current_worker_assignments AS current
|
SELECT current.* FROM ticket_current_worker_assignments AS current
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ use yoi_workspace_server::hosts::{
|
|||||||
};
|
};
|
||||||
use yoi_workspace_server::store::{
|
use yoi_workspace_server::store::{
|
||||||
AccountRecord, ApiTokenRecord, SqliteWorkspaceStore, UserRecord,
|
AccountRecord, ApiTokenRecord, SqliteWorkspaceStore, UserRecord,
|
||||||
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingState,
|
WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
|
||||||
};
|
};
|
||||||
use yoi_workspace_server::{
|
use yoi_workspace_server::{
|
||||||
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
|
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
|
||||||
@@ -456,7 +456,7 @@ fn append_workspace_runtime_sources(
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|binding| {
|
.filter(|binding| {
|
||||||
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
|
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
|
||||||
&& binding.state == WorkspaceRuntimeBindingState::Verified
|
&& binding.revoked_at.is_none()
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
})
|
})
|
||||||
@@ -787,11 +787,7 @@ mod tests {
|
|||||||
public_key: "unused".to_owned(),
|
public_key: "unused".to_owned(),
|
||||||
public_key_fingerprint: "unused".to_owned(),
|
public_key_fingerprint: "unused".to_owned(),
|
||||||
binding_id: "binding-test".to_owned(),
|
binding_id: "binding-test".to_owned(),
|
||||||
state: WorkspaceRuntimeBindingState::Verified,
|
|
||||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
|
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(),
|
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||||
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
@@ -815,11 +811,7 @@ mod tests {
|
|||||||
public_key: "unused".to_owned(),
|
public_key: "unused".to_owned(),
|
||||||
public_key_fingerprint: "unused".to_owned(),
|
public_key_fingerprint: "unused".to_owned(),
|
||||||
binding_id: "binding-test".to_owned(),
|
binding_id: "binding-test".to_owned(),
|
||||||
state: WorkspaceRuntimeBindingState::Verified,
|
|
||||||
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
|
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(),
|
created_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||||
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
updated_at: "2026-09-01T00:00:00Z".to_owned(),
|
||||||
revoked_at: None,
|
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();
|
let conn = Connection::open(&path).unwrap();
|
||||||
configure_sqlite(&conn).unwrap();
|
configure_sqlite(&conn).unwrap();
|
||||||
seed_migration_workspace(&conn);
|
seed_migration_workspace(&conn);
|
||||||
conn.execute_batch("INSERT INTO repository_secret_operations VALUES('space','create-key','intent','credential','key',7,'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','created',NULL);
|
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','created');
|
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','created');").unwrap();
|
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 key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
|
||||||
let mut ciphertext = b"private bytes".to_vec();
|
let mut ciphertext = b"private bytes".to_vec();
|
||||||
key.seal_in_place_append_tag(
|
key.seal_in_place_append_tag(
|
||||||
@@ -92,7 +92,7 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority()
|
|||||||
&mut ciphertext,
|
&mut ciphertext,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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);
|
drop(conn);
|
||||||
let master_key = dir.path().join("repository-secrets.master-key");
|
let master_key = dir.path().join("repository-secrets.master-key");
|
||||||
std::fs::write(&master_key, [42; 32]).unwrap();
|
std::fs::write(&master_key, [42; 32]).unwrap();
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
|
|||||||
"flow_source_contents",
|
"flow_source_contents",
|
||||||
"repositories",
|
"repositories",
|
||||||
"workspace_runtime_bindings",
|
"workspace_runtime_bindings",
|
||||||
"workspace_runtime_verifications",
|
"workspace_runtime_binding_verification_archive",
|
||||||
|
"workspace_runtime_verification_archive",
|
||||||
"workspace_runtime_binding_audit",
|
"workspace_runtime_binding_audit",
|
||||||
"workspace_signing_identities",
|
"workspace_signing_identities",
|
||||||
"workspace_signing_identity_provisioning_operations",
|
"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: identity.public_key.clone(),
|
||||||
public_key_fingerprint: String::new(),
|
public_key_fingerprint: String::new(),
|
||||||
binding_id: "binding-drive-test".to_string(),
|
binding_id: "binding-drive-test".to_string(),
|
||||||
state: StoredRuntimeBindingState::Verified,
|
|
||||||
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
|
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
|
||||||
workspace_key_id: None,
|
|
||||||
workspace_public_key_fingerprint: None,
|
|
||||||
workspace_trust_id: None,
|
|
||||||
created_at: "1".into(),
|
created_at: "1".into(),
|
||||||
updated_at: "1".into(),
|
updated_at: "1".into(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
|
|||||||
@@ -160,12 +160,10 @@ impl LaunchFixture {
|
|||||||
if remote {
|
if remote {
|
||||||
fixture.runtime_id = "ticket-worker-real-http".to_string();
|
fixture.runtime_id = "ticket-worker-real-http".to_string();
|
||||||
use worker_runtime::workspace_issuer::{
|
use worker_runtime::workspace_issuer::{
|
||||||
InMemoryWorkspaceClaimReplayProtection,
|
InMemoryWorkspaceClaimReplayProtection, WorkspaceCapabilityVerifier,
|
||||||
InMemoryWorkspaceRuntimeVerificationAuthority, RuntimeVerificationSigner,
|
WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
|
||||||
WorkspaceCapabilityVerifier, WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustState,
|
|
||||||
};
|
};
|
||||||
let runtime_identity = RuntimeIdentityMaterial::generate(&fixture.runtime_id).unwrap();
|
let runtime_identity = RuntimeIdentityMaterial::generate(&fixture.runtime_id).unwrap();
|
||||||
let signer = RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap();
|
|
||||||
let workspace_identity = fixture
|
let workspace_identity = fixture
|
||||||
.api
|
.api
|
||||||
.signing_identities
|
.signing_identities
|
||||||
@@ -204,13 +202,11 @@ impl LaunchFixture {
|
|||||||
display_name: "Ticket Worker real HTTP Runtime".into(),
|
display_name: "Ticket Worker real HTTP Runtime".into(),
|
||||||
base_url: endpoint.clone(),
|
base_url: endpoint.clone(),
|
||||||
public_key: runtime_identity.public_key.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(),
|
binding_id: "binding-launch-test".to_string(),
|
||||||
state: StoredRuntimeBindingState::Configured,
|
|
||||||
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
|
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(),
|
created_at: TEST_CREATED_AT.into(),
|
||||||
updated_at: TEST_CREATED_AT.into(),
|
updated_at: TEST_CREATED_AT.into(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
@@ -228,6 +224,7 @@ impl LaunchFixture {
|
|||||||
.await
|
.await
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
let runtime_id = fixture.runtime_id.clone();
|
||||||
fixture.runtime_server = Some(tokio::spawn(async move {
|
fixture.runtime_server = Some(tokio::spawn(async move {
|
||||||
worker_runtime::http_server::serve_runtime_http_with_workspace_auth(
|
worker_runtime::http_server::serve_runtime_http_with_workspace_auth(
|
||||||
runtime,
|
runtime,
|
||||||
@@ -235,10 +232,7 @@ impl LaunchFixture {
|
|||||||
None,
|
None,
|
||||||
worker_runtime::http_server::WorkspaceRuntimeHttpAuth {
|
worker_runtime::http_server::WorkspaceRuntimeHttpAuth {
|
||||||
verifier,
|
verifier,
|
||||||
signer,
|
runtime_id,
|
||||||
verifications: Arc::new(
|
|
||||||
InMemoryWorkspaceRuntimeVerificationAuthority::default(),
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -258,27 +252,12 @@ impl LaunchFixture {
|
|||||||
cached_status: "active".into(),
|
cached_status: "active".into(),
|
||||||
timeout: std::time::Duration::from_secs(5),
|
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 =
|
remote_config.workspace_authorization =
|
||||||
Some(crate::hosts::WorkspaceRuntimeAuthorization::new(
|
Some(crate::hosts::WorkspaceRuntimeAuthorization::new(
|
||||||
fixture.api.store.clone(),
|
fixture.api.store.clone(),
|
||||||
fixture.api.signing_identities.clone(),
|
fixture.api.signing_identities.clone(),
|
||||||
backend_url.clone(),
|
backend_url.clone(),
|
||||||
Some(verified),
|
binding,
|
||||||
));
|
));
|
||||||
fixture.api.runtime.register_or_replace(
|
fixture.api.runtime.register_or_replace(
|
||||||
RemoteWorkerRuntime::new(remote_config, TEST_WORKSPACE_ID.to_string(), backend_url)
|
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()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
binding.revoked_at = Some("revoked".into());
|
binding.revoked_at = Some("revoked".into());
|
||||||
binding.state = StoredRuntimeBindingState::Revoked;
|
|
||||||
f.api
|
f.api
|
||||||
.store
|
.store
|
||||||
.upsert_workspace_runtime_binding_record(binding, true)
|
.upsert_workspace_runtime_binding_record(binding, true)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -102,7 +102,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
|||||||
"workspace_resource_keys",
|
"workspace_resource_keys",
|
||||||
"workspace_runtime_binding_audit",
|
"workspace_runtime_binding_audit",
|
||||||
"workspace_runtime_bindings",
|
"workspace_runtime_bindings",
|
||||||
"workspace_runtime_verifications",
|
"workspace_runtime_binding_verification_archive",
|
||||||
|
"workspace_runtime_verification_archive",
|
||||||
"workspace_signing_identities",
|
"workspace_signing_identities",
|
||||||
"workspace_signing_identity_audit",
|
"workspace_signing_identity_audit",
|
||||||
"workspace_signing_identity_provisioning_operations",
|
"workspace_signing_identity_provisioning_operations",
|
||||||
|
|||||||
@@ -228,36 +228,7 @@ impl WorkspaceSigningIdentityService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
|
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
|
||||||
let identity = self.get_validated(workspace_id)?;
|
let (_, signing_key) = self.select_signing_key(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",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(signing_key.sign(payload).as_ref().to_vec())
|
Ok(signing_key.sign(payload).as_ref().to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,9 +237,20 @@ impl WorkspaceSigningIdentityService {
|
|||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
claims: &WorkspaceCapabilityClaims,
|
claims: &WorkspaceCapabilityClaims,
|
||||||
) -> Result<String> {
|
) -> 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 input = workspace_capability_signing_input(claims).map_err(capability_error)?;
|
||||||
let signature = self.sign(workspace_id, input.bytes())?;
|
let signature = signing_key.sign(input.bytes());
|
||||||
assemble_workspace_capability_token(input, &signature).map_err(capability_error)
|
assemble_workspace_capability_token(input, signature.as_ref()).map_err(capability_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_material(&self, workspace_id: &str) -> Result<()> {
|
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<()> {
|
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
|
let material = self
|
||||||
.materials
|
.materials
|
||||||
.load(&identity.private_material_ref)?
|
.load(&identity.private_material_ref)?
|
||||||
@@ -316,8 +331,8 @@ impl WorkspaceSigningIdentityService {
|
|||||||
"Workspace signing private material is missing",
|
"Workspace signing private material is missing",
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
let public_key =
|
let signing_key = material.signing_key(&identity.workspace_id, &identity.key_id)?;
|
||||||
material.validate_and_public_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)?;
|
let fingerprint = public_key_fingerprint(&public_key)?;
|
||||||
if identity.public_key.as_deref() != Some(public_key.as_str())
|
if identity.public_key.as_deref() != Some(public_key.as_str())
|
||||||
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.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",
|
"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),
|
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!(
|
assert!(matches!(
|
||||||
swapping_service.sign("workspace-1", payload),
|
swapping_service.sign("workspace-1", payload),
|
||||||
Err(Error::WorkspaceSigningIdentity { ref code, .. })
|
Err(Error::WorkspaceSigningIdentity { ref code, .. })
|
||||||
|
|||||||
@@ -26,11 +26,96 @@ auto-read files, references, retained tail, and TaskStore context. Exceeding it
|
|||||||
warning and continues without discarding the generated summary or stripping auto-read/retained
|
warning and continues without discarding the generated summary or stripping auto-read/retained
|
||||||
content. `0` disables this warning. Normal request-time safety checks still apply after activation.
|
content. `0` disables this warning. Normal request-time safety checks still apply after activation.
|
||||||
|
|
||||||
## Large sessions
|
## Compactor inputs and continuity policy
|
||||||
|
|
||||||
Large-session compaction should not send an entire prefix transcript as the summary input. Prefer bounded overview/index inputs plus exploration, then keep the retained tail small and explicit.
|
The compactor receives the previous Host-produced summary in full as mandatory source
|
||||||
|
material, separately from the bounded overview of this Segment's ordinary prefix. It must
|
||||||
|
preserve ongoing user goals, directives, completion conditions, and unresolved work from
|
||||||
|
that summary and from the current Segment's user messages. Recent progress and the
|
||||||
|
TaskStore snapshot are supporting evidence, not replacements for the original request.
|
||||||
|
Important wording or ambiguous completion state should be checked through exploration
|
||||||
|
rather than reconstructed from recent activity.
|
||||||
|
|
||||||
This keeps compaction cost predictable and avoids turning a context-recovery mechanism into the largest prompt in the session.
|
The first model input includes:
|
||||||
|
|
||||||
|
- `## Initial compaction summary (full text)`, when a prior summary exists, containing its
|
||||||
|
complete body without overview truncation;
|
||||||
|
- recently touched file references, when available;
|
||||||
|
- the full current Session TaskStore snapshot;
|
||||||
|
- `## Conversation overview/index`, a bounded index of the ordinary prefix selected for
|
||||||
|
summarization, with exploration tools for omitted details.
|
||||||
|
|
||||||
|
The inherited summary is selected by `LoggedSessionHistoryOrigin::CompactionSummary`,
|
||||||
|
not by message role or pasted text. Only the Host-produced opening compaction summary
|
||||||
|
receives that origin. Auto-read contents, related-file context, and TaskStore snapshots
|
||||||
|
remain `DerivedSummary` and follow ordinary prefix/retention policy. A user or system
|
||||||
|
message containing `[Compacted context summary]` is not sufficient to qualify.
|
||||||
|
|
||||||
|
Restore recognizes one explicitly adopted legacy construction: the first history item of
|
||||||
|
an `AnnotatedSegmentStart` with `compacted_from: Some(...)`, whose origin is
|
||||||
|
`DerivedSummary`, whose role is System, and whose text starts with the exact Host summary
|
||||||
|
marker. That item is interpreted as `CompactionSummary` in restored history. Restore does
|
||||||
|
not promote later derived items, user messages, unknown origins, or marker-bearing items
|
||||||
|
without compacted lineage, and does not rewrite the original durable record.
|
||||||
|
|
||||||
|
## Retained tail
|
||||||
|
|
||||||
|
The retained-token budget selects an ordinary history tail, at item granularity. Retained
|
||||||
|
entries remain in the replacement Segment with their item and metadata preserved, except
|
||||||
|
for documented binary-attachment projection. They are outside the prefix overview.
|
||||||
|
|
||||||
|
The previous `CompactionSummary` is an explicit exception: its full body is supplied to the
|
||||||
|
compactor regardless of whether the cut lies before or after it, but the old summary is
|
||||||
|
excluded from both the ordinary overview and the retained tail. The newly generated
|
||||||
|
summary supersedes it. Keeping both would duplicate inherited context on every compaction.
|
||||||
|
Other derived context is not removed merely because its origin is `DerivedSummary`.
|
||||||
|
|
||||||
|
## Limits and large sessions
|
||||||
|
|
||||||
|
Large-session compaction does not send an entire prefix transcript as the initial input.
|
||||||
|
The ordinary prefix uses a bounded overview/index plus exploration. This keeps the
|
||||||
|
transcript portion predictable without allowing an overview limit to erase inherited goals.
|
||||||
|
|
||||||
|
`overview_target_tokens` is a packing target, `overview_warning_tokens` emits a warning,
|
||||||
|
and `overview_deadline_tokens` switches the ordinary overview to a coarse index. These
|
||||||
|
limits and `compact.overview_tokens` accounting cover the overview only, not the separate
|
||||||
|
full inherited summary. Small targets, warnings, and coarse fallback must not shorten or
|
||||||
|
omit that summary.
|
||||||
|
|
||||||
|
The inherited summary still occupies the compactor's context. It counts against
|
||||||
|
`worker_context_max_tokens`; moving it outside the overview does not exempt it from the
|
||||||
|
hard input-occupancy check. If mandatory input exceeds that cap, compaction fails explicitly
|
||||||
|
before sending the oversized request, leaving the source Segment active rather than
|
||||||
|
silently clipping source material or activating an incomplete replacement. The budget guard
|
||||||
|
is installed through the internal Worker's normal `WorkerInterceptor`, not an Engine
|
||||||
|
interceptor that Worker startup would replace. Before a provider usage measurement exists,
|
||||||
|
the estimate includes the full input, system prompt, and installed tool definitions. Later
|
||||||
|
provider input measurements include that request shape already. A budget warning is an
|
||||||
|
append-only history item and is also checked against the hard cap; if it cannot fit alongside
|
||||||
|
the mandatory input, the run fails explicitly. Provider context-window failures likewise do
|
||||||
|
not activate a replacement; estimates are not an exact model-token guarantee.
|
||||||
|
|
||||||
|
`summary_target_tokens` guides the new `write_summary` output. A nonzero
|
||||||
|
`summary_max_tokens` is a hard output bound: an oversized result receives a rewrite request;
|
||||||
|
if the rewritten output still exceeds the maximum, compaction fails without activation.
|
||||||
|
This output bound is distinct from overview packing, the compactor input cap, and the
|
||||||
|
advisory `result_context_max_tokens` warning on the assembled replacement.
|
||||||
|
|
||||||
|
## Assessment boundary
|
||||||
|
|
||||||
|
Deterministic integration tests inspect the actual `LlmClient::Request` to prove full
|
||||||
|
inherited-summary transport, separation from overview packing, provenance-aware retention,
|
||||||
|
shared history-restoration behavior, hard-limit rejection without activation, and inheritance
|
||||||
|
through at least three chained compactions. Their `write_summary` outputs are predetermined
|
||||||
|
mock responses: they do **not** prove that a model understood or semantically retained user
|
||||||
|
goals, directives, or completion conditions. Shared restore-path coverage is not a complete
|
||||||
|
cold Runtime restart assessment.
|
||||||
|
|
||||||
|
Live semantic assessment remains pending. It should use a real model on long, repeated
|
||||||
|
compactions and compare the resulting summaries against the original request and explicit
|
||||||
|
completion conditions, including facts near the end of a long inherited summary and facts
|
||||||
|
absent from recent progress. Report semantic omissions separately from successful transport;
|
||||||
|
a mock echo or a successful activation cannot establish semantic continuity.
|
||||||
|
|
||||||
## History integrity
|
## History integrity
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# Compaction summary input validation (T-738)
|
||||||
|
|
||||||
|
## Baseline and scope
|
||||||
|
|
||||||
|
Implementation starts at `6a12b6cc346402b7a10208685038967171b09cb3`.
|
||||||
|
Compared with the Ticket's inspected `3263d501`, the compaction input, capture and
|
||||||
|
prompt contracts are unchanged; intervening Worker edits concern state-contract
|
||||||
|
naming, resource/manage-worker projections, and shared state. The defects reported
|
||||||
|
against `3263d501` therefore still apply to this checkout.
|
||||||
|
|
||||||
|
The change fixes mandatory previous-summary transport and the continuity policy.
|
||||||
|
It does not add Journal integration (T-745), a separate directive ledger, exploration
|
||||||
|
of historical Segments, or a comprehensive internal-compactor audit log. See
|
||||||
|
[the current design](../design/compaction.md) for the input and retention contracts.
|
||||||
|
|
||||||
|
## Acceptance evidence
|
||||||
|
|
||||||
|
| Ticket condition | Deterministic evidence |
|
||||||
|
| --- | --- |
|
||||||
|
| 1: full initial input, long summary and recent activity | `full_initial_summary_survives_small_overview_budgets_and_both_retention_extremes` observes the actual first compactor `LlmClient::Request`; full body equality, one occurrence, and no clipped duplicate in overview. Summary exceeds 4,000 characters with directives/end sentinel after the long prefix, plus 80 recent messages. |
|
||||||
|
| 2: small target/warning/fallback, context accounting, hard failure | Same matrix uses target=1 and warning/deadline settings 0/1. `inherited_summary_counts_against_hard_input_limit_and_failure_does_not_activate` compares the same overview with/without a mandatory summary above the cap: no compactor request, no replacement Segment, unchanged active metadata/history on rejection. `budget_warning_is_durable_and_keeps_full_summary_in_first_model_request` verifies the warning survives in the next request. Interceptor tests cover request-shape estimation, measured occupancy without double counting, and a warning that cannot fit. |
|
||||||
|
| 3: absent summary, restore, retained boundary, other derived context | Matrix covers no retained tail and retaining all ordinary items. `pasted_markers_and_other_derived_context_remain_ordinary_overview_material` covers absent summary, auto-read, TaskStore, ordinary system, user paste and later derived markers. `restored_legacy_summary_requires_compacted_lineage_first_position_origin_role_and_marker` covers the exact adopted old Host seed boundary and its counterexamples. Invalid or duplicate typed Host summaries fail closed. |
|
||||||
|
| 4: privacy and lightweight overview | Existing tool-argument/tool-result/reasoning omission tests remain. `host_summary_remains_private_to_compaction_not_observation_or_memory_evidence` verifies overview, search, read, and evidence exclude Host summary, other derived/system and reasoning bodies. The persisted retained-history paging fixture includes the new Host summary origin and still excludes derived content. Public provenance maps to the existing excluded `DerivedSummary` class; no public observation/Memory capability is added. |
|
||||||
|
| 5: prompt and shortening policy | Resource policy test covers initial summary, segment user messages, prohibitions, completion conditions, changes/retractions, uncertainty, TaskStore distinction and retained tail. Shrink-policy and budget-warning tests cover the corresponding priorities. Output-hard-max integration test exercises the actual rewrite path and explicit failure if output remains oversized. |
|
||||||
|
| 6: at least three chained compacts | `three_chained_compactions_transport_each_complete_predecessor_without_retaining_old_summaries` starts with a long summary carrying a late original-goal sentinel; each of three compacts receives a full predecessor and a new user directive containing temporary recovery/SubWorker collection instructions. It checks each actual initial request, one current typed summary, predecessor lineage and persisted result. |
|
||||||
|
| 7: tests/check/format and current design | Commands below pass; design input, privacy, retention, limit and assessment descriptions are updated. |
|
||||||
|
|
||||||
|
## Executed validation
|
||||||
|
|
||||||
|
During implementation, the smallest filters were run first:
|
||||||
|
|
||||||
|
- `cargo test -p worker --lib build_summary_prompt_tests::overview`
|
||||||
|
- `cargo test -p worker --lib compact_worker_`
|
||||||
|
- `cargo test -p worker --lib session_history::`
|
||||||
|
- `cargo test -p worker --lib session_capture::`
|
||||||
|
- `cargo test -p worker --lib build_summary_prompt_tests::compact_prompt_and_shrink`
|
||||||
|
- `cargo test -p worker --lib malformed_or_duplicate_host`
|
||||||
|
- `cargo test -p worker --test compact_events_test inherited_summary_counts_against_hard_input_limit_and_failure_does_not_activate`
|
||||||
|
- `cargo test -p worker --test compact_events_test` (25 tests)
|
||||||
|
- `cargo test -p session-store --lib retained_history_pages_twelve_turns`
|
||||||
|
|
||||||
|
Final default-configuration validation:
|
||||||
|
|
||||||
|
- `cargo check` at workspace root (normal compile closure, including TUI/Server)
|
||||||
|
- `cargo test -p worker` (library, integration targets, doctests)
|
||||||
|
- `cargo test -p session-store` (library, integration targets, doctests)
|
||||||
|
- `cargo fmt --all -- --check`
|
||||||
|
- `git diff --check HEAD`
|
||||||
|
- `env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts` (0 unapproved, 0 stale exceptions)
|
||||||
|
|
||||||
|
No added contract depends on an optional Worker feature. Full-workspace tests,
|
||||||
|
`--all-targets`, live provider calls, E2E, Nix and Docker builds were not run.
|
||||||
|
|
||||||
|
## Unverified semantic and operational behavior
|
||||||
|
|
||||||
|
Predetermined mock `write_summary` results prove transport, input policy and
|
||||||
|
replacement behavior, **not** a real model's semantic retention or comprehension.
|
||||||
|
Live semantic evaluation remains pending: use a real model across at least three
|
||||||
|
compacts, score original scope/prohibitions/completion/exception-reporting duties
|
||||||
|
and explicit subsequent changes separately from transport success, and verify that
|
||||||
|
temporary recovery procedures do not displace the continuing request. Do not credit
|
||||||
|
an echo fixture as semantic evidence.
|
||||||
|
|
||||||
|
The restore fixture exercises public `rewind_to` and the shared typed restoration
|
||||||
|
path over persisted annotated records. It is not a cold Runtime restart test.
|
||||||
|
Manual and automatic compaction use the same `compact_impl`; existing automatic
|
||||||
|
resume tests pass, but no separate live automatic-compaction semantic evaluation
|
||||||
|
was run. Initial context accounting is an estimate, not exact provider tokenization;
|
||||||
|
provider failure must still fail the run without activation.
|
||||||
@@ -8,11 +8,11 @@ Never use the live dogfood Server or Runtime as the first startup test for a new
|
|||||||
|
|
||||||
1. Build the production entrypoints: `cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
|
1. Build the production entrypoints: `cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
|
||||||
2. Run the focused and dependent tests for the changed contracts, followed by workspace-root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`.
|
2. Run the focused and dependent tests for the changed contracts, followed by workspace-root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`.
|
||||||
3. Exercise the provisioning and operational checks in [Workspace ↔ Runtime authentication](server-runtime-auth.md) against isolated Server DB, Runtime data, and ports. The Workspace owner must create the binding, the Runtime operator must install the Workspace issuer bundle, and the challenge proof must become verified.
|
3. Exercise the provisioning and operational checks in [Workspace ↔ Runtime authentication](server-runtime-auth.md) against isolated Server DB, Runtime data, and ports. The Workspace owner must configure the Runtime endpoint and pinned Runtime key, the Runtime operator must install the Workspace issuer bundle, and actual signed requests must authenticate. A connection-test result describes only that attempt; no stored verification state is a readiness prerequisite.
|
||||||
4. Have an external supervisor or operator restart Server and Runtime from the same validated source commit/build artifacts. A Worker hosted by the target Runtime must never terminate its own Runtime.
|
4. Have an external supervisor or operator restart Server and Runtime from the same validated source commit/build artifacts. A Worker hosted by the target Runtime must never terminate its own Runtime.
|
||||||
5. Verify post-restart readiness through the Workspace Runtime projection, ping, Worker list/create, protocol subscription, and a Runtime-to-Server source-proof operation before treating the environment as healthy.
|
5. Verify post-restart readiness through the Workspace Runtime projection, ping, Worker list/create, protocol subscription, and a Runtime-to-Server source-proof operation before treating the environment as healthy.
|
||||||
|
|
||||||
The former isolated startup shell harness depended on removed Server-global trust commands and is intentionally not a fallback smoke path. New automated startup coverage must provision the same Workspace-scoped binding and challenge authority used by production rather than recreating global trust or seeding private authority directly.
|
The former isolated startup shell harness depended on removed Server-global trust commands and is intentionally not a fallback smoke path. New automated startup coverage must configure the same Workspace-scoped connection and explicit Runtime-side Workspace trust used by production, then exercise per-request authentication rather than recreating global trust or seeding a past verification result.
|
||||||
|
|
||||||
## What to record
|
## What to record
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ A report is useful when it explains:
|
|||||||
- what design boundary was missing
|
- what design boundary was missing
|
||||||
- what evidence was observed
|
- what evidence was observed
|
||||||
|
|
||||||
For a Remote Runtime rollout, also record the source commit, binary artifact digest, Server schema version, Runtime `binding_id`, Workspace `key_id` and `workspace_trust_id`, and typed HTTP/WebSocket outcomes. A successful document response does not outweigh visible UI, console, or API errors.
|
For a Remote Runtime rollout, also record the source commit, binary artifact digest, Server schema version, Runtime `binding_id` and pinned key fingerprint, the current Workspace `key_id` and public fingerprint, the Runtime-side trust registration/revocation, and typed HTTP/WebSocket outcomes. Do not copy a Runtime trust ID into the Server connection configuration or treat a successful connection test as persisted authentication. A successful document response does not outweigh visible UI, console, or API errors.
|
||||||
|
|
||||||
## Runtime command caveat
|
## Runtime command caveat
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,25 @@
|
|||||||
# Workspace ↔ Runtime 認証
|
# Workspace ↔ Runtime 認証
|
||||||
|
|
||||||
Yoi の Remote Runtime 認証は Workspace ごとの署名 identity を authority とする。
|
Yoi の Remote Runtime 認証は Workspace ごとの署名 identity を authority とする。
|
||||||
|
認証の成否は実際の HTTP / WebSocket request ごとに決まり、保存済みの「verified」状態や過去の接続テスト結果を前提にしない。
|
||||||
Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わない。
|
Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わない。
|
||||||
|
|
||||||
## Authority
|
## Authority
|
||||||
|
|
||||||
- Server DB は Workspace ごとの signing identity と Runtime binding を保持する。
|
- Server DB は Workspace ごとの signing identity と Runtime 接続設定(binding)を保持する。
|
||||||
- Runtime は `trust-workspace` で受理した `WorkspacePublicIdentityBundle` を保持する。
|
- binding は endpoint、固定した Runtime public key/fingerprint、`binding_id`、authentication mode、管理者による失効を保持する。Workspace key や Runtime 側の trust ID のコピー、認証成功状態は保持しない。
|
||||||
|
- Runtime は管理者が `trust-workspace` で受理した `WorkspacePublicIdentityBundle` を保持する。
|
||||||
- bundle は `workspace_id`、Workspace `key_id`、Workspace public key/fingerprint、Backend URL を固定する。
|
- bundle は `workspace_id`、Workspace `key_id`、Workspace public key/fingerprint、Backend URL を固定する。
|
||||||
- Server → Runtime の各 HTTP / WebSocket request は、対象 Workspace の signing identity で短命な capability token を発行する。
|
- Server → Runtime の各 HTTP / WebSocket request は、その時点の Workspace signing identity で短命な capability token を発行する。署名時には claims の Workspace・key ID・fingerprint が選択した identity と一致することを検証する。秘密鍵materialがない場合は fallback せず拒否する。
|
||||||
- Runtime は request method、`path_and_query`、body digest、permission、Workspace、Runtime、key ID、現在の trust ID、expiry、JTI を検証する。
|
- Runtime は現在の明示的な Workspace trust、署名、request method、`path_and_query`、body digest、permission、Workspace、Runtime、必要に応じ Worker、key ID/fingerprint、expiry、JTI を検証する。過去の handshake や verification record は不要である。
|
||||||
- Runtime → Server の source proof は Runtime identity で署名し、対象 Workspace と bundle の Backend URL を audience に固定する。
|
- Runtime → Server の source proof は Runtime identity で署名し、対象 Workspace と bundle の Backend URL を audience に固定する。この proof の契約は変わらない。
|
||||||
- Server は現在の Workspace Runtime binding、Runtime public key、Backend public URL、request target、body digest、permission、expiry、replay state を検証する。
|
- Server は現在の Workspace Runtime binding、Runtime public key、Backend public URL、request target、body digest、permission、expiry、replay state を検証する。
|
||||||
|
|
||||||
旧 Server identity/trust 管理 command と旧 Runtime-side Server trust command、旧 Runtime auth key flags は廃止済みである。これらに相当する Server-global trust を fallback として使ってはならない。
|
旧 Server identity/trust 管理 command と旧 Runtime-side Server trust command、旧 Runtime auth key flags は廃止済みである。これらに相当する Server-global trust を fallback として使ってはならない。
|
||||||
|
|
||||||
## Provisioning
|
## Provisioning
|
||||||
|
|
||||||
The enrollment input includes the exact Runtime-issued `workspace_trust_id` from
|
Runtime 側の Workspace trust 登録と Server 側の Runtime 接続設定は独立した管理操作である。Runtime が発行する `trust_id` を Server にコピーする必要はない。
|
||||||
`trust-workspace add/show`. Export the current Workspace public bundle first,
|
|
||||||
accept it on the Runtime, then register the Runtime public bundle, endpoint and
|
|
||||||
trust ID. Server verification binds that enrollment ID; it must never invent an
|
|
||||||
ID from the key fingerprint or assume an initial counter value. Re-enrollment
|
|
||||||
requires registering the newly issued trust ID, even when the key is unchanged.
|
|
||||||
|
|
||||||
1. Runtime identity を初期化する。
|
1. Runtime identity を初期化する。
|
||||||
|
|
||||||
@@ -31,17 +28,16 @@ requires registering the newly issued trust ID, even when the key is unchanged.
|
|||||||
yoi-runtime identity show
|
yoi-runtime identity show
|
||||||
```
|
```
|
||||||
|
|
||||||
2. The Workspace owner exports the current Workspace issuer public bundle from Workspace identity settings.
|
2. Workspace owner が Workspace identity settings から現在の Workspace issuer public bundle を export する。
|
||||||
3. The operator accepts that bundle on the Runtime and reads the issued trust ID.
|
3. Runtime 管理者が bundle を確認し、Runtime に明示的に登録する。
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
yoi-runtime trust-workspace add --bundle <workspace-issuer-bundle.json>
|
yoi-runtime trust-workspace add --bundle <workspace-issuer-bundle.json>
|
||||||
yoi-runtime trust-workspace show --workspace-id <workspace-id>
|
yoi-runtime trust-workspace show --workspace-id <workspace-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
4. The owner registers the Runtime public bundle, endpoint and exact `workspace_trust_id` in Settings > Runtimes.
|
4. Workspace owner が Settings > Runtimes に Runtime public bundle と endpoint を登録する。接続設定には Runtime key を固定するが、Workspace key や Runtime の trust ID は登録しない。
|
||||||
5. The Server sends a signed challenge bound to that trust ID, the Workspace key fingerprint and the binding ID, then verifies the Runtime proof and acknowledgement.
|
5. 接続テストまたは通常操作を実行する。実際の Workspace-signed request が Runtime の現在の trust と照合される。接続テスト結果はその試行の診断であり、永続的な認証状態を作らず、以後の request の成功を保証しない。
|
||||||
6. Normal Workspace-signed requests become available only after the verified binding is committed. Revoking and re-enrolling the same key still requires the newly issued Runtime trust ID.
|
|
||||||
|
|
||||||
同じ Runtime identity は異なる Workspace から独立して信頼できる。trust record、replay protection、binding、失効はすべて Workspace scope で評価する。
|
同じ Runtime identity は異なる Workspace から独立して信頼できる。trust record、replay protection、binding、失効はすべて Workspace scope で評価する。
|
||||||
|
|
||||||
@@ -55,29 +51,41 @@ requires registering the newly issued trust ID, even when the key is unchanged.
|
|||||||
- 最大 8 MiB
|
- 最大 8 MiB
|
||||||
- 最大 4,096 records
|
- 最大 4,096 records
|
||||||
- exact Workspace / Runtime identity
|
- exact Workspace / Runtime identity
|
||||||
- `key_id` と public key fingerprint、opaque `trust_id`
|
- `key_id` と public key fingerprint、管理 record の opaque `trust_id`
|
||||||
- normalized Backend URL
|
- normalized Backend URL
|
||||||
- 同じ `key_id` に異なる鍵materialを割り当てないこと。実際の置換・失効は新しい `trust_id` を発行する
|
- 同じ `key_id` に異なる鍵materialを割り当てないこと。実際の置換・失効は新しい `trust_id` を発行するが、この ID は request token や Server binding に含めない
|
||||||
- list は `offset` / `limit` 必須で、1 page 最大 100 records
|
- list は `offset` / `limit` 必須で、1 page 最大 100 records
|
||||||
|
|
||||||
## Local token
|
## Local token
|
||||||
|
|
||||||
`--local-token` は明示的な local Runtime 呼び出し専用であり、Remote Workspace binding の代替ではない。Workspace issuer auth が有効な Remote Runtime request は Workspace capability token を使う。
|
`--local-token` は明示的な local Runtime 呼び出し専用であり、Remote Workspace binding の代替ではない。Workspace issuer auth が有効な Remote Runtime request は Workspace capability token を使う。
|
||||||
|
|
||||||
## Rotation と失効
|
## Key 変更と失効
|
||||||
|
|
||||||
Workspace signing key または Runtime key の変更は、現在 binding を置き換える明示的な provisioning 操作として行う。古い key/trust ID、古い Runtime key、revoked binding、失効済み token、replayed JTI は即時拒否する。
|
Workspace signing identity は各 request の発行時に現在の authority から選択する。Workspace public key が変更された場合、Runtime 管理者が現在の public bundle を明示的に `trust-workspace replace` で受理する必要がある。Workspace key のコピーを更新するためだけに Server の Runtime 接続設定を置き換える操作は不要である。これは Workspace key rotation の新しい production API を定義するものではない。
|
||||||
|
|
||||||
Server の Runtime cache は現在の persisted binding 全体と照合する。endpoint、Runtime public key/fingerprint、`binding_id`、Workspace `key_id`、`workspace_trust_id` の変更を検知した場合、stale client を利用しない。
|
Runtime 管理者は `trust-workspace revoke` で Workspace trust を失効できる。Runtime は現在の trust と一致しない key、失効済み trust、expired token、replayed JTI を拒否する。同じ key の再登録も管理者の明示的な操作で行い、request の自己申告による trust 登録は行わない。
|
||||||
|
|
||||||
|
Runtime key や endpoint の変更は Server の接続設定を明示的に置き換える。Server の Runtime cache と authorizer は現在の接続設定(endpoint、Runtime public key/fingerprint、`binding_id`、authentication mode、管理者による失効)と照合し、replaced/revoked binding を使う stale client を拒否する。接続設定の一致は送信先の選択であって、認証成功の証拠ではない。
|
||||||
|
|
||||||
|
接続先が到達不能・認証失敗でも、owner は観測した `binding_id` を指定して接続設定を修復できる。取得できた active Worker は置換を遮断するが、Worker 一覧の取得失敗を「idle の証明」とは扱わず、認証成功を修復の前提にもしない。接続設定の置換は Worker の停止・削除ではなく、Runtime 削除の資源確認とは別の操作である。
|
||||||
|
|
||||||
|
## 保存形式の移行
|
||||||
|
|
||||||
|
Server schema90 は既存 binding の endpoint、Runtime key、binding ID、明示的失効を保持し、古い認証状態と検証記録を履歴専用 archive に移す。履歴は要求の認可には参照しない。既存 Workspace identity や接続設定の作り直しは不要である。
|
||||||
|
|
||||||
|
Runtime の旧形式の Workspace trust は、検証済みの公開鍵と管理者の Active/Revoked 設定を保持して読み込む。形式の移行だけでは失効させず、すでに保存されている Revoked 設定を自動で解除することもしない。旧 verification file は認可に使用しない。消費済み token の replay 記録は引き継ぎ、同じ鍵の再登録でリセットしない。
|
||||||
|
|
||||||
|
API と capability claims の旧 handshake 契約は維持しないため、Backend・Runtime・Web は対応する版へ更新する。接続テストは更新後の実認証を確認するものであり、登録を有効化する手順ではない。
|
||||||
|
|
||||||
## 運用確認
|
## 運用確認
|
||||||
|
|
||||||
Remote Runtime を有効化した後は次を確認する。
|
Remote Runtime を設定した後は次を確認する。
|
||||||
|
|
||||||
1. `yoi-runtime trust-workspace show --workspace-id <workspace-id>` が期待する bundle を表示する。
|
1. `yoi-runtime trust-workspace show --workspace-id <workspace-id>` が期待する active bundle を表示する。
|
||||||
2. Workspace Settings の Runtime binding が `verified` で、現在の `binding_id`・`workspace_key_id`・`workspace_trust_id` と verification evidence を表示する。
|
2. Workspace Settings の Runtime 接続設定が期待する endpoint と固定 Runtime key を示し、失効していない。
|
||||||
3. Runtime ping、Worker list/create、`worker.protocol` subscription が Workspace-signed token で成功する。
|
3. 接続テストの今回の結果を確認し、Runtime ping、Worker list/create、`worker.protocol` subscription が実際の Workspace-signed request で成功することを確認する。保存済み「verified」表示や verification evidence は確認項目ではない。
|
||||||
4. wrong Workspace、wrong Runtime、wrong target/body、expired token、revoked/replaced binding、replayed JTI が拒否される。
|
4. wrong Workspace、wrong Runtime、wrong target/body、expired token、revoked Runtime trust、replaced/revoked binding、replayed JTI が拒否される。
|
||||||
5. Runtime → Server source proof が configured Backend public URL audience と一致し、spoofed headers だけでは認証されない。
|
5. Runtime → Server source proof が configured Backend public URL audience と一致し、spoofed headers だけでは認証されない。
|
||||||
|
|
||||||
Server / Runtime の再起動は live reload ではない authority 変更を反映するときだけ、通常の運用権限と migration gate に従って行う。実行中プロセスを開発 Worker が無断で停止してはならない。
|
Server / Runtime の再起動は live reload ではない authority 変更を反映するときだけ、通常の運用権限と migration gate に従って行う。実行中プロセスを開発 Worker が無断で停止してはならない。
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ closeは案件の実作業拘束を終える一方、T-715の終端担当責任
|
|||||||
|
|
||||||
## 旧保存データの移行
|
## 旧保存データの移行
|
||||||
|
|
||||||
独立T-739 sourceではTicket component schema7→9、Workspace schema89→90を使用する。component6からの既存移行も維持する。
|
T-739 sourceではTicket component schema7→9を使用する。Workspaceは現developのauth/runtime connection移行89→90をそのまま保持し、案件移行90→91を追加する。旧89からはauth→案件の順に進み、現90からは案件移行だけを行う。component6からの既存移行も維持する。
|
||||||
|
|
||||||
| 旧workflow state | 現行案件状態 | 終了理由 |
|
| 旧workflow state | 現行案件状態 | 終了理由 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
# schema86→87のSSH移行: 異なる時刻形式の文字列比較でfingerprintがNULLになる
|
||||||
|
|
||||||
|
調査日: 2026-10-10
|
||||||
|
報告された起動エラー:
|
||||||
|
|
||||||
|
`yoi-server: store error: workspace schema migration failed: sqlite error: NOT NULL constraint failed: workdir_create_credential_candidates.credential_fingerprint`
|
||||||
|
|
||||||
|
## 原因
|
||||||
|
|
||||||
|
`crates/workspace-server/src/frozen_repository_key_migration.rs:262–266`のcredential candidate移行は、旧credential IDと保存counterの組から`repository_key_cutover`へLEFT JOINし、`m.fingerprint`を新しいNOT NULL列に挿入する。
|
||||||
|
|
||||||
|
JOIN条件の`m.created_at <= o.updated_at`が、RFC3339の鍵作成時刻とepoch-millis文字列のWorkdir更新時刻を、そのままTEXTとして比較している。日時としての順序を判定していないため、有効な古い鍵のJOINが外れ、fingerprintがNULLになる。
|
||||||
|
|
||||||
|
- 鍵作成時刻: `repository_access.rs:2256`の`Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true)`。
|
||||||
|
- Workdir作成・更新時刻: `server.rs:30936–30940`の`now_registry_timestamp()`はepochミリ秒の文字列。reserveでもこれを使用する(`server.rs:24272–24308`)。
|
||||||
|
|
||||||
|
同じ異形式比較はoperation本体のcredential/host trust移行にもある(migration L260–261)。candidateだけ直してもoperation snapshotのfingerprintが欠落する問題が残る。
|
||||||
|
|
||||||
|
## 実データによる確認
|
||||||
|
|
||||||
|
ユーザーから共有済みの`yoi.local/server/server.db`をNodeの`DatabaseSync({readOnly: true})`と`PRAGMA query_only=ON`で照合した。秘密鍵、暗号化envelope、master keyは取得していない。migrationやDB更新も実行していない。
|
||||||
|
|
||||||
|
- DBはschema86のまま。
|
||||||
|
- 新candidateへ移される5件すべてがfailed Workdir createのcredential候補で、旧retentionが各1件あるため成功済み・unretainedのarchive経路には入らない。
|
||||||
|
- 5件とも参照先の鍵は存在する。対応するmutation receiptも各1件あり、証拠欠落ではない。
|
||||||
|
- 5件ともSQLiteのTEXT比較はfalse、実際の時刻比較はtrue。
|
||||||
|
|
||||||
|
代表例:
|
||||||
|
|
||||||
|
| 項目 | 値 |
|
||||||
|
| --- | --- |
|
||||||
|
| operation | `call_mgmyCNofdQMZ8uyI5tg0mBhv` |
|
||||||
|
| credential | `workspace-default`、旧保存counter 1 |
|
||||||
|
| 鍵created_at | `2026-09-16T18:44:56.736Z` |
|
||||||
|
| Workdir updated_at | `1790173821030` |
|
||||||
|
| Workdir updated_atを日時に変換 | `2026-09-23T14:30:21.030Z` |
|
||||||
|
| SQLiteの文字列比較 | false |
|
||||||
|
| 日時としての比較 | true |
|
||||||
|
|
||||||
|
他の4件では鍵作成が`2026-09-10T13:09:52.113Z`、Workdir更新は9/23または9/29で、同じ問題になる。
|
||||||
|
|
||||||
|
## 修正方針(この調査では未実装)
|
||||||
|
|
||||||
|
- frozen migration内で旧データの時刻形式を明示的に読み、同じ時刻型/epoch-millisへ正規化して比較する。文字列比較やSQLの安易なCASTに頼らない。
|
||||||
|
- `created_at <= updated_at`の安全確認そのものは削除しない。旧IDと保存counterが削除後に再利用された鍵を、過去のcandidateに誤って結び付けることを防ぐ条件である。
|
||||||
|
- candidateだけでなくoperation本体のcredential/host trust等、同じ境界の比較を確認する。
|
||||||
|
- 正規化不能・不正な時刻や真に必要な証拠が欠落した場合は、NOT NULLエラーになる前に文脈付きで拒否し、transactionをrollbackする。
|
||||||
|
|
||||||
|
既存の`frozen_repository_key_tests.rs:35–42`の共通fixtureは、鍵とWorkdirの時刻をすべて`created`に揃えており、実データの異形式を再現していない。RFC3339鍵時刻+epoch-millis Workdir時刻での移行成功と、実時刻が操作より後の再作成鍵を拒否するケースが必要。テスト追加時は`docs/development/rust-testing-strategy.md`に従う。
|
||||||
|
|
||||||
|
## 影響・実施範囲
|
||||||
|
|
||||||
|
schema86→87はtransaction内で実行され、version87の記録とcommitは最後に行う(migration L29、L349–350)。今回のDBでもschema86と旧テーブルを確認した。ただし、それより前のschema更新まで含めた全起動処理が一括で元に戻るという意味ではない。
|
||||||
|
|
||||||
|
NOT NULLを外す、適当なfingerprintを入れる、candidate/retentionを消して通す、といったDB修復は行うべきではない。正しい既存データをmigrationが誤判定しているので、移行コード側を修正する。
|
||||||
|
|
||||||
|
調査時点で追加したのは本報告のみ。以下は、その後のユーザー指示による修正記録。
|
||||||
|
|
||||||
|
## 修正と検証(2026-10-10)
|
||||||
|
|
||||||
|
- RFC3339/epoch-millisをRustで読み、temporary tableのUTC秒・ナノ秒へ正規化して比較するよう修正。credential/host trustのoperation snapshot、candidate、旧receiptの時刻条件に適用した。
|
||||||
|
- タイムゾーン・sub-millisecondの順序を維持し、元の履歴時刻やreceipt照合用の文字列、暗号化AADは書き換えない。永続schemaの変更・version bumpは不要。
|
||||||
|
- 不正な時刻、欠けた鍵、操作より新しい再利用鍵は文脈付きで拒否しrollback。NOT NULLを緩めたり候補を削除したりしない。
|
||||||
|
- 独立コード確認で見つかった、親operationのない旧candidateがINNER JOINから落ちて消える経路も、コピー前の拒否とrollbackテストで保護した。追修正に追加のblocking findingなし。
|
||||||
|
- 偽時刻`created`や日付だけのfixtureは実際のRFC3339へ修正。新規回帰は混在形式・同時刻・offset・ナノ秒精度、各SSH参照での新しい鍵拒否、不正時刻、孤立候補の保持を検証する。
|
||||||
|
- 最小の混在形式テスト、migrationモジュール、公開migration dry-run/applyテスト、孤立候補テストを順に実行して成功。最終の`cargo test -p yoi-workspace-server`はlibrary **815 passed / 1 ignored**、binary **11 passed**、doctest失敗なし。
|
||||||
|
- root `cargo check`、`cargo fmt --all -- --check`、変更したincludeファイルの明示的`rustfmt --check`、`git diff --check HEAD`も成功。
|
||||||
|
- 稼働DBでのmigration、ビルド済みバイナリの配備、サービスの停止・再起動は行っていない。更新はユーザー操作のまま。
|
||||||
|
|
||||||
|
作業中、テスト担当SubWorkerはcwd基準の読取で、scopeに付与した別ディレクトリのテスト規則を参照できなかった(絶対pathもinvalid logical filesystem pathで拒否)。編集なしを確認して停止し、親でテストを実装した。読取専用レビュー担当はroot cwdで規則・ソースを読めた。cwdとscopeにまたがる論理pathの案内・検証がツール上の改善点。
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Workdir 削除のマウント残留と結果不明の診断
|
||||||
|
|
||||||
|
## 調査範囲
|
||||||
|
|
||||||
|
ユーザーの「workdirを消せない原因を調査して」という依頼に対し、保存済み Server DB を
|
||||||
|
`mode=ro` / `PRAGMA query_only=ON` で照合し、対象ディレクトリ・マウント・現行実装を
|
||||||
|
観察した。削除再実行、アンマウント、DB 更新、サービス/プロセス操作、実装修正は行っていない。
|
||||||
|
調査中にも operation の記録が更新されたため、以下は最後に読んだ状態であり、過去の全試行に
|
||||||
|
同じ原因があったと断定するものではない。
|
||||||
|
|
||||||
|
Workspace: `0197a949-4b6b-7f2a-9d9a-1f87e3a4c5b6`、Runtime: `arcadia`。
|
||||||
|
表示に含まれる次の2つの Workdir ID は別の対象である。
|
||||||
|
|
||||||
|
## T-721: 残留した JuiceFS マウントによる拒否(確認済み)
|
||||||
|
|
||||||
|
- Workdir: `001a11ab3e830000007` / `T-721 JuiceFS Drive foundation`
|
||||||
|
- operation: `wdr_1c0840acc66f27b7e048bdfff8f80b76`
|
||||||
|
- 最後に読んだ attempt: `01a12682-3b33-7e43-b4c6-4e57eb469912`
|
||||||
|
- 更新時刻: `2026-10-10T15:50:31.515343065+00:00`
|
||||||
|
- 結果: `failed` / `mount_present` / `retryable=1`
|
||||||
|
|
||||||
|
`findmnt` で、次の2つが `JuiceFS:yoi-t721` / `fuse.juicefs` のマウントとして残っていることを
|
||||||
|
確認した(パスは `yoi.local` 以下の論理パス)。
|
||||||
|
|
||||||
|
```text
|
||||||
|
workdirs/001a11ab3e830000007/checkout/.yoi/dev/juicefs-t721/mount
|
||||||
|
workdirs/001a11ab3e830000007/checkout/.yoi/dev/juicefs-t721/mount2
|
||||||
|
```
|
||||||
|
|
||||||
|
両エントリの属性参照は `Transport endpoint is not connected` で失敗した。
|
||||||
|
`ps -C juicefs` に現在のプロセスはなかった。マウント解除されていない FUSE の残留状態と
|
||||||
|
整合するが、プロセスが終了した経緯までは今回確認していない。
|
||||||
|
|
||||||
|
Registry の cleanliness は `clean` だが、これはマウントを安全に削除できる証明ではない。
|
||||||
|
実ディレクトリの `materialization.json` は `cleanup_pending`。
|
||||||
|
現行 `crates/worker-runtime/src/working_directory/cleanup.rs` は、削除前にマウント境界を
|
||||||
|
検査し、外部/未知のマウントを自動解除しない。Server は
|
||||||
|
`working_directory_cleanup_mount_present` を `mount_present` として扱う。
|
||||||
|
これは安全性を維持する拒否であり、cleanliness 判定の不具合とは異なる。
|
||||||
|
|
||||||
|
解消には、所有者が保存先と利用状況を確認して両マウントを安全に解除することが必要。
|
||||||
|
その後、マウントがないことを確認して通常の Workdir 削除を再実行する。
|
||||||
|
マウント先を含む `rm -rf`、強制的なメタデータ改変、未知のプロセス停止は解消手段にしない。
|
||||||
|
マウント解除後も ignored な実験データ等の保護により別の拒否が起こる可能性があり、今回の
|
||||||
|
確認は次回の削除成功を保証しない。
|
||||||
|
|
||||||
|
## 別 ID: HTTP タイムアウトと物理削除の時間差が疑われる(未確定)
|
||||||
|
|
||||||
|
- Workdir: `001a111ca9d33000002`
|
||||||
|
- operation: `wdr_fccf7c6e9ee6cbb931ea2f9b82198650`
|
||||||
|
- attempt: `01a12682-411e-7720-b829-f9c9c0148886`
|
||||||
|
- created: `2026-10-10T15:50:32.984845992+00:00`
|
||||||
|
- updated: `2026-10-10T15:50:43.018819445+00:00`
|
||||||
|
- 結果: `failed` / `provider_cleanup_failed` / `retryable=1`
|
||||||
|
|
||||||
|
要求から失敗まで約10.034秒。Registry には `corrupted` / cleanliness `unknown` として
|
||||||
|
残っている一方、調査時点で `yoi.local/workdirs/001a111ca9d33000002` は存在しない。
|
||||||
|
`.cleanup-authority` にも残存ファイルはなかった。
|
||||||
|
|
||||||
|
現行実装では `RemoteRuntimeConfig::new` の timeout は10秒で、DELETE はその共通
|
||||||
|
blocking HTTP client を使う。Runtime の HTTP handler は `spawn_blocking` で同期的な
|
||||||
|
検査/削除の完了を待つ。クライアントのタイムアウトは、この blocking 削除処理の取り消しや
|
||||||
|
物理削除の失敗を意味しない。
|
||||||
|
|
||||||
|
このため「Server が10秒で応答待ちを打ち切った後、Runtime の物理削除が完了した」という
|
||||||
|
説明と整合する。ただし、元の `remote_runtime_timeout` 等の診断を今回の観察面で取得できず、
|
||||||
|
別の操作でディレクトリがなくなった可能性も排除できない。物理パスがないだけで Backend の
|
||||||
|
削除完了を捏造してはいけない。通常の再試行で Runtime の厳密な not-found 応答を確認できれば、
|
||||||
|
現行 Server は registry 削除を完了する経路を持つ。今回、その再試行は行っていない。
|
||||||
|
|
||||||
|
## 診断面の障壁と改善案
|
||||||
|
|
||||||
|
`crates/workspace-server/src/server.rs` の `workdir_cleanup_failure_category` は、既知の
|
||||||
|
cleanup code を分類し、それ以外を `provider_cleanup_failed` に落とす。
|
||||||
|
`crates/workspace-server/src/hosts.rs` は transport timeout を `remote_runtime_timeout`
|
||||||
|
に区別するが、上記分類では失敗結果不明との区別が失われる。
|
||||||
|
|
||||||
|
また removal operation は bounded category のみを永続化し、元の diagnostic code を残さない。
|
||||||
|
これは秘密情報を含み得る message を保存しない方針とは両立するが、現行の失敗ログも
|
||||||
|
Workdir/operation/runtime/category のみで、ユーザー案内の「相関 Runtime diagnostics を調べる」
|
||||||
|
を保存済み情報だけでは完遂できなかった。`runtime.json` の diagnostics にも対象 ID / operation
|
||||||
|
を含む記録は見つからなかった。
|
||||||
|
|
||||||
|
改善案は、秘密を含む raw message を公開・永続化せず、transport timeout を「削除結果不明」と
|
||||||
|
して区別し、許可された診断コードと operation/attempt/Workdir の相関を残すこと。
|
||||||
|
さらに、完了まで時間のかかる guarded cleanup を通常の10秒 control request と同じ同期要求で
|
||||||
|
扱う契約を見直すこと。単にタイムアウトを延長するだけでは結果不明の状態はなくならない。
|
||||||
|
今回は調査報告のみで、これらの実装変更はしていない。
|
||||||
+4
-113
@@ -1437,15 +1437,10 @@
|
|||||||
},
|
},
|
||||||
"public_bundle": {
|
"public_bundle": {
|
||||||
"$ref": "#/components/schemas/RuntimePublicIdentityBundle"
|
"$ref": "#/components/schemas/RuntimePublicIdentityBundle"
|
||||||
},
|
|
||||||
"workspace_trust_id": {
|
|
||||||
"description": "Exact Runtime-issued enrollment identity from `trust-workspace add/show`.\nA key fingerprint alone cannot distinguish revocation and re-enrollment.",
|
|
||||||
"type": "string"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"public_bundle",
|
"public_bundle",
|
||||||
"workspace_trust_id",
|
|
||||||
"endpoint"
|
"endpoint"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -7747,15 +7742,6 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"RuntimeConnectionDisplayState": {
|
|
||||||
"enum": [
|
|
||||||
"configured",
|
|
||||||
"verified",
|
|
||||||
"unavailable",
|
|
||||||
"revoked"
|
|
||||||
],
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"RuntimeConnectionTestFailureKind": {
|
"RuntimeConnectionTestFailureKind": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"authentication",
|
"authentication",
|
||||||
@@ -7772,6 +7758,7 @@
|
|||||||
},
|
},
|
||||||
"RuntimeConnectionTestResponse": {
|
"RuntimeConnectionTestResponse": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Observation of an actual signed ping, not a grant or cached approval.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"actual_protocol_version": {
|
"actual_protocol_version": {
|
||||||
"format": "uint32",
|
"format": "uint32",
|
||||||
@@ -7788,9 +7775,6 @@
|
|||||||
"checked_at": {
|
"checked_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"connection_state": {
|
|
||||||
"$ref": "#/components/schemas/RuntimeConnectionDisplayState"
|
|
||||||
},
|
|
||||||
"diagnostics": {
|
"diagnostics": {
|
||||||
"default": [],
|
"default": [],
|
||||||
"items": {
|
"items": {
|
||||||
@@ -7820,16 +7804,6 @@
|
|||||||
"status": {
|
"status": {
|
||||||
"$ref": "#/components/schemas/RuntimeConnectionTestStatus"
|
"$ref": "#/components/schemas/RuntimeConnectionTestStatus"
|
||||||
},
|
},
|
||||||
"verification": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/RuntimeVerificationEvidenceSummary"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"workspace_id": {
|
"workspace_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
@@ -7838,7 +7812,6 @@
|
|||||||
"workspace_id",
|
"workspace_id",
|
||||||
"runtime_id",
|
"runtime_id",
|
||||||
"binding_id",
|
"binding_id",
|
||||||
"connection_state",
|
|
||||||
"checked_at",
|
"checked_at",
|
||||||
"status",
|
"status",
|
||||||
"expected_protocol_version"
|
"expected_protocol_version"
|
||||||
@@ -8292,57 +8265,6 @@
|
|||||||
],
|
],
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"RuntimeVerificationEvidenceSummary": {
|
|
||||||
"additionalProperties": false,
|
|
||||||
"properties": {
|
|
||||||
"binding_id": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"last_checked_at": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"last_outcome": {
|
|
||||||
"$ref": "#/components/schemas/RuntimeVerificationOutcome"
|
|
||||||
},
|
|
||||||
"runtime_public_key_fingerprint": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"verified_at": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"workspace_key_id": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"workspace_public_key_fingerprint": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"workspace_trust_id": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": [
|
|
||||||
"last_checked_at",
|
|
||||||
"last_outcome",
|
|
||||||
"binding_id",
|
|
||||||
"workspace_key_id",
|
|
||||||
"workspace_public_key_fingerprint",
|
|
||||||
"workspace_trust_id",
|
|
||||||
"runtime_public_key_fingerprint"
|
|
||||||
],
|
|
||||||
"type": "object"
|
|
||||||
},
|
|
||||||
"RuntimeVerificationOutcome": {
|
|
||||||
"enum": [
|
|
||||||
"verified",
|
|
||||||
"challenge_issued",
|
|
||||||
"verification_failed",
|
|
||||||
"connectivity_failed"
|
|
||||||
],
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"RuntimeWorkerCompletionsRequest": {
|
"RuntimeWorkerCompletionsRequest": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -15528,43 +15450,14 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
"WorkspaceRuntimeBindingState": {
|
|
||||||
"enum": [
|
|
||||||
"configured",
|
|
||||||
"verified",
|
|
||||||
"revoked"
|
|
||||||
],
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"WorkspaceRuntimeBindingSummary": {
|
"WorkspaceRuntimeBindingSummary": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
|
"description": "Binding identity and explicit revocation only. Each signed request authenticates\nusing the current Workspace signing identity, not a stored approval.",
|
||||||
"properties": {
|
"properties": {
|
||||||
"binding_id": {
|
"binding_id": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"connection_state": {
|
"revoked_at": {
|
||||||
"$ref": "#/components/schemas/RuntimeConnectionDisplayState"
|
|
||||||
},
|
|
||||||
"state": {
|
|
||||||
"$ref": "#/components/schemas/WorkspaceRuntimeBindingState"
|
|
||||||
},
|
|
||||||
"verification": {
|
|
||||||
"anyOf": [
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/RuntimeVerificationEvidenceSummary"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "null"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"workspace_key_id": {
|
|
||||||
"type": [
|
|
||||||
"string",
|
|
||||||
"null"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"workspace_trust_id": {
|
|
||||||
"type": [
|
"type": [
|
||||||
"string",
|
"string",
|
||||||
"null"
|
"null"
|
||||||
@@ -15572,8 +15465,6 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"state",
|
|
||||||
"connection_state",
|
|
||||||
"binding_id"
|
"binding_id"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -16328,7 +16219,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e"
|
"x-yoi-source-digest": "sha256:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
You are a context compaction assistant. Your job is to hand the next session a structured summary plus pointers to the files it actually needs — not a narrative transcript of the conversation.
|
You are a context compaction assistant. Your job is to hand the next session a structured summary plus pointers to the files it actually needs — not a narrative transcript of the conversation.
|
||||||
|
|
||||||
The conversation input is a bounded overview/index, not the full transcript. Treat tool result bodies and reasoning as intentionally omitted unless a tool exposes more detail. If you receive a compact worker budget warning, stop broad exploration immediately, read only if absolutely necessary, and call `write_summary`.
|
The conversation input contains a bounded overview/index, not the full transcript, and, when present, a separately labelled initial compaction summary in full. The initial summary is mandatory source material, not an overview preview or something to recover through exploration. Tool result bodies and reasoning are intentionally omitted; raw system prompts and reasoning are not exposed by session exploration. If you receive a compact worker budget warning, stop broad exploration immediately, read only if absolutely necessary to confirm load-bearing user wording, and call `write_summary` while preserving ongoing goals and directives.
|
||||||
|
|
||||||
|
## Continuity policy
|
||||||
|
|
||||||
|
- Treat ongoing goals and directives in the initial summary and this segment's user messages as the primary inheritance targets. Explore user entries to confirm important original wording when needed; do not reconstruct the request only from recent progress.
|
||||||
|
- Preserve scope, prohibitions, completion conditions, exceptions and duties to explain exceptional outcomes wherever possible. Prefer original wording for conditions whose loss would change the request's meaning.
|
||||||
|
- Interruptions, recovery, stash and SubWorker collection procedures do not by themselves retract or satisfy the original request. TaskStore, implementation and test progress do not replace user goals or completion conditions.
|
||||||
|
- Apply explicit changes or retractions in later user messages. The initial summary is not more authoritative than the original user text. Preserve uncertainty when conflict, completion or continued applicability cannot be established.
|
||||||
|
- When shrinking or near the budget limit, compress duplicate explanations and stale work details first. Do not replace ongoing goals and directives with recent procedures. Do not copy the whole transcript or accumulate expired directives unconditionally.
|
||||||
|
- The retained tail stays verbatim in the next session and is outside the summarized prefix. Preserve directives from the compacted prefix in the summary so they do not disappear from both summary and tail; integrate them with the continuing goals from the initial summary.
|
||||||
|
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
1. Read the provided overview/index and current TaskStore snapshot.
|
1. Read the full initial compaction summary when supplied, the overview/index and current TaskStore snapshot. Keep user intent distinct from task/progress metadata.
|
||||||
2. If the provided index is not enough, call `ShowOverview` on the pinned capture. Use `SearchEntries` to locate relevant entries, then `ReadEntry` with the returned stable entry reference to inspect only what is needed.
|
2. If the provided index is not enough, call `ShowOverview` on the pinned capture. Use `SearchEntries` to locate relevant entries, then `ReadEntry` with the returned stable entry reference to inspect only what is needed.
|
||||||
3. Use `Read` to inspect referenced Workdir files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
3. Use `Read` to inspect referenced Workdir files before deciding what the next session needs. Prefer skimming over blind inclusion.
|
||||||
4. For UTF-8 text files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately. Images and binary files cannot be injected; record them with `add_reference` so the next session can fetch them with `ViewImage` or the appropriate read tool.
|
4. For UTF-8 text files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately. Images and binary files cannot be injected; record them with `add_reference` so the next session can fetch them with `ViewImage` or the appropriate read tool.
|
||||||
@@ -39,4 +49,4 @@ Produce the summary in this exact format:
|
|||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
- Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for.
|
- Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for.
|
||||||
- Follow the summary target stated in the run input; if asked to shrink, call `write_summary` again with a shorter version.
|
- Follow the summary target stated in the run input; if asked to shrink, call `write_summary` again with a shorter version using the continuity policy above, not by dropping ongoing user intent.
|
||||||
|
|||||||
@@ -673,39 +673,15 @@
|
|||||||
{
|
{
|
||||||
"path": "crates/worker-runtime/src/workspace_issuer.rs",
|
"path": "crates/worker-runtime/src/workspace_issuer.rs",
|
||||||
"kind": "saved_data",
|
"kind": "saved_data",
|
||||||
"reason": "LegacyWorkspaceIssuerTrustRecord/LegacyVerificationDocumentの保存済みenrollment・verification keyとfixture。旧trustをrevokedへ移行し、旧verification bytesをarchiveするがlive recordsを空にするconsumerがある。削除すると既存記録を読めないか、旧capabilityを再承認なしに復活させない移行検証を失う。",
|
"reason": "LegacyWorkspaceIssuerTrustRecordの保存済みenrollment keyとfixture。旧形式の管理者によるWorkspace信頼登録を読み取る境界だけに綴りを保持する。通常要求の署名検証は現行の信頼済み公開鍵を使い、旧counterや過去のverification記録は認可に使わない。削除すると既存の信頼登録を読めなくなる。",
|
||||||
"lines": [
|
"lines": [
|
||||||
{
|
{
|
||||||
"sha256": "56e399dc4e828e66677d4ad09b4c66a9c09658322b3886a06b4ae688c63dfc84",
|
"sha256": "56e399dc4e828e66677d4ad09b4c66a9c09658322b3886a06b4ae688c63dfc84",
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"sha256": "ea16c387d0048571bd0a5576f1358af32acf63f8ee5ebe08c262da83c5a04d88",
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha256": "bfd48831eb694b879a2859d42dba00fe74a4a817177acc61a44795cbd19b1056",
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha256": "94af0abdfb45c712e9cd1949ac6a15d1a21c3b271f004c9282f81b5fd5ae4f5d",
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"sha256": "d099e3a7216349c0ef30026967b8fe344e3b08b58a77bad6437468502b56bf71",
|
"sha256": "d099e3a7216349c0ef30026967b8fe344e3b08b58a77bad6437468502b56bf71",
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha256": "bed791d41faa85b38602c238d30460681e6d9ebbe56c4c08b843dd1971a589b2",
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha256": "ce36822c65f785f330e42b7d37241062d994dc4e1c18abaa327b7996da5b6521",
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"sha256": "5f4aed5ed50f2bbf54bbb9293948c3294eb2d30086d0fbe7cb07899efb5c96ba",
|
|
||||||
"count": 1
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -1259,7 +1235,7 @@
|
|||||||
{
|
{
|
||||||
"path": "crates/workspace-server/src/frozen_repository_key_migration.rs",
|
"path": "crates/workspace-server/src/frozen_repository_key_migration.rs",
|
||||||
"kind": "saved_data",
|
"kind": "saved_data",
|
||||||
"reason": "schema 86から87の旧SSH key/secret/operation/retention/source列名。旧secret AADと暗号化payloadを開き新key identityへresealし、元operation fingerprint・retention・auditを対応付けるconsumerがある。削除すると既存秘密鍵を復号できず、SSH retry/retentionやauditを失う。曖昧な対応はrollbackし原文archiveを保持する。",
|
"reason": "schema 86から87の旧SSH key/secret/operation/retention/source列名。旧secret AADと暗号化payloadを開き新key identityへresealし、元operation fingerprint・retention・auditを対応付けるconsumerがある。削除すると既存秘密鍵を復号できず、SSH retry/retentionやauditを失う。曖昧な対応はrollbackし原文archiveを保持する。 時刻修正でhashが変わったcredential/host trust/候補の3 JOIN行は同じ旧保存列を読み、鍵作成UTC秒・ナノ秒を操作時刻と比較する。旧列を変えると実DBのSSH snapshotを対応付けられないため、この3行の例外だけを同期した。",
|
||||||
"lines": [
|
"lines": [
|
||||||
{
|
{
|
||||||
"sha256": "95284617f203ea22df2ba1fe49903db531a0dd68dbf8654981505516a07dfcce",
|
"sha256": "95284617f203ea22df2ba1fe49903db531a0dd68dbf8654981505516a07dfcce",
|
||||||
@@ -1378,15 +1354,15 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "8948263f582419232838423d99d269c423dee4b21c8189d97fde3de97afc2104",
|
"sha256": "a69d60581d924b868edf66ad6bfe63a47f44483aed13d7467df3303ad4b5d3c5",
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "9b2d38cea48eaf100fdcd2444036da17a0a8104090b429e6c64e56255d45d9c0",
|
"sha256": "1397df3babbcbfdb0c3a7e12ce1a5bcaf396ef295880b4d2885bcd0b54fb6d1b",
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "f8333763f67f3a40ae3e245f523e2b0dca6a7b6c1b42d5aa61273bcbf649c5f2",
|
"sha256": "291ea2e36bf20850c631eed6a64064c193c90d2fe2b346b0a13bc5031a1dbde9",
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1428,14 +1404,14 @@
|
|||||||
{
|
{
|
||||||
"path": "crates/workspace-server/src/frozen_repository_key_tests.rs",
|
"path": "crates/workspace-server/src/frozen_repository_key_tests.rs",
|
||||||
"kind": "saved_data",
|
"kind": "saved_data",
|
||||||
"reason": "legacy_repository_keysの旧SSH key/暗号secret/operation/retention/Workdir fixtureとrollback SQL。旧AADを本当に開いてresealし、元fingerprintでのreplay、retention、audit、不可逆な旧Workdirのarchiveを保持することを検証する。綴りを変えると既存secretの復号可能性と不整合rollbackの検証を失う。",
|
"reason": "legacy_repository_keysの旧SSH key/暗号secret/operation/retention/Workdir fixtureとrollback SQL。旧AADを本当に開いてresealし、元fingerprintでのreplay、retention、audit、不可逆な旧Workdirのarchiveを保持することを検証する。綴りを変えると既存secretの復号可能性と不整合rollbackの検証を失う。 既存fixtureの5 INSERT行は偽時刻からRFC3339へ変更されたためhashのみ同期した。旧credential/host trust表とcounterの綴り・実AAD入力は不変であり、移行成功と別鍵retention/欠けたreceiptのrollbackを同じ旧形式で検証する。",
|
||||||
"lines": [
|
"lines": [
|
||||||
{
|
{
|
||||||
"sha256": "a62f153e1748caa20f7cb81656b8c6b29d32b01bf533a3ffaf36fb5be9a5ae3b",
|
"sha256": "a62f153e1748caa20f7cb81656b8c6b29d32b01bf533a3ffaf36fb5be9a5ae3b",
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "de6b3a38afad3d08bc4bcb534c1374446ae59b28ed08235643fd61c3b42992fd",
|
"sha256": "59be6fd4e7c21afa91e2618bde39dbf7b2325be929723cabc19d75eebc0fe606",
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1451,11 +1427,11 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "af41b9c5edf66189130020a79568cad6b86b56835935e06969d34a4cc0569e93",
|
"sha256": "d7a003785fdc9de68e8f980f9ccc49360b5ecf9c382a9d34b348d3070cca724c",
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "9ec9ccf2b8e304c1cb1ef9b755a5cfe4d203879a9787394a9c6edca59c6fddca",
|
"sha256": "3ce073da6367a945e80717f01b342a6ec3eb6495fce1f8bf651ce9406584d8b3",
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1499,7 +1475,7 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "b2495afb6d5b32f13e77eb7a7fa4866f12531d3ed059aa44288ccb69d0fa4e26",
|
"sha256": "04ea12161ed1f19d3edd90d0619d03e80a9aaa5c9854b46e437e3c87852ec0ff",
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1507,7 +1483,7 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "4a4ea05d99ba512fbe0ecb7c31d176a08c59bb6e8b1156624bee907ad4dfc103",
|
"sha256": "906649318474b0ef47fa35be64c7f9b7f6fe1e50aa477d6bb424137afe079fb2",
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -1929,7 +1905,7 @@
|
|||||||
{
|
{
|
||||||
"path": "crates/workspace-server/src/migration_entrypoint_tests.rs",
|
"path": "crates/workspace-server/src/migration_entrypoint_tests.rs",
|
||||||
"kind": "saved_data",
|
"kind": "saved_data",
|
||||||
"reason": "schema-87 retention/removal policyとschema-86 SSH key fixture。public migrate_database/open entrypointが旧DBのpolicy binding・terminal worker evidence・暗号鍵を移すことを検証する。旧SQLを現行名へ変えると公開入口を通る実データ保持/rollbackの証明を失う。",
|
"reason": "schema-87 retention/removal policyとschema-86 SSH key fixture。public migrate_database/open entrypointが旧DBのpolicy binding・terminal worker evidence・暗号鍵を移すことを検証する。旧SQLを現行名へ変えると公開入口を通る実データ保持/rollbackの証明を失う。 公開dry-run/applyのcredential保存行は偽時刻からRFC3339へ変更されたためhashのみ同期した。元暗号AADとkey receiptに結び付いた旧表名を維持し、旧DBを公開入口から移す証明を残す。",
|
||||||
"lines": [
|
"lines": [
|
||||||
{
|
{
|
||||||
"sha256": "2a4cb9f8dacaf637103fe369c8ce5106bbb442097e644483c3900cdd98fade81",
|
"sha256": "2a4cb9f8dacaf637103fe369c8ce5106bbb442097e644483c3900cdd98fade81",
|
||||||
@@ -1948,7 +1924,7 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"sha256": "de6b3a38afad3d08bc4bcb534c1374446ae59b28ed08235643fd61c3b42992fd",
|
"sha256": "59be6fd4e7c21afa91e2618bde39dbf7b2325be929723cabc19d75eebc0fe606",
|
||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -2182,5 +2158,86 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "crates/workspace-server/src/frozen_repository_key_migration.rs",
|
||||||
|
"kind": "saved_data",
|
||||||
|
"reason": "prepare_repository_cutover_timesの旧schema86 snapshot照合SQL3行(credential operation、host trust operation、credential candidate)。それぞれ実在した旧保存counter列を取り出し、鍵作成時刻が操作時刻より新しい再利用鍵や証拠欠落をコピー前に拒否する。綴りを現行列へ置換すると旧DBを読めず、NULL fingerprint挿入前の安全なrollbackとretained SSH retry証拠の保持を失う。新writerの状態名として使わない。",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"sha256": "fdd4d8fa9046d50591afa726d7d12c455020571a5b74f1e6c748556b36f50db6",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "765772579ddb0b8e014bed913836f722a25f97a4293abcc823e2d23ab7d0b3b4",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "c368d019ed0698f769513bf2da22f2f8894f66ebfa4242554544d5ca05316773",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "crates/workspace-server/src/frozen_repository_key_tests.rs",
|
||||||
|
"kind": "saved_data",
|
||||||
|
"reason": "legacy_ssh_snapshot_timesとnewer_reused_snapshot_keys/invalid_authority_timestampsの旧保存入力。credential時刻UPDATE3箇所とhost trust時刻UPDATE2箇所は混在形式の成功・操作より新しい鍵の拒否・不正時刻のrollbackを分けて検証する。host INSERTとfailed Workdir snapshot UPDATEは実旧receiptを作り、main credentialをNULLにする1行は候補だけの不整合を隔離する。credential/host trustのsource文字列とmatch各2行は不正値を実旧表のcreated_atへ入れて文脈付き拒否を確認する。旧列/表の綴りを変えるとfixtureを操作できず、過去のSSH snapshotに別鍵を結び付けない検証を失う。",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"sha256": "f49461fbbb42bf1a13996379f031645d31ab7f794e64c6cc66fe6a5e5d14ecf8",
|
||||||
|
"count": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "651e611e62c3be58f95b2c5aae3f95f98ea3858180b77132968bef7ae4616165",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "f78c4397f9e4c8058fa8d320949e0cc23ef8c47c37a643cf73e91cb84c4d4b38",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "0c49320a2fcff35a9fffd8eb02517c97e538006c614b6243d02757fd12c23727",
|
||||||
|
"count": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "4e64ea62da159a158270bb0e6f9c1086090a73deb826f4b8df02438f8065fd2d",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "48ffab07c0e4a66b82944f7001e6cc8ffef8a964f01a1b348db14537204f6a7f",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "18341cc32d7711507e0ec849ba9291c7265944149d9b7814c72a6310272546f4",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "739474845828016af6993686f70b3d716fbe2f37cba59aa0f2e9a3659e40ce2c",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "cc05720883982d4345c5059aebbfc6f2dfc4d07126d2ee0425acde502c4a762b",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "crates/workspace-server/src/frozen_repository_key_tests.rs",
|
||||||
|
"kind": "saved_data",
|
||||||
|
"reason": "assert_timestamp_cutover_rolls_backの旧credential表存在と旧retention件数の2行、およびrepository_key_cutover_rejects_orphan_candidates_without_dropping_source_evidenceの旧表存在1行。失敗後にもschema86の秘密鍵・retained retry参照・候補が消えないことを実旧表名で確認する保存データ保持のassertionであり、旧名を現行出力に許可する契約ではない。削除するとtimestamp/candidate拒否時の部分移行やデータ喪失を検出できなくなる。",
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"sha256": "d7080c861537f04caf0ddddc36c166eb965b74bc8dbd57f5d4a3ef8232804c66",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "821421c002e9020248029b8a74b6b86ab3abd9c37d719cd3bf2572b096f54e9f",
|
||||||
|
"count": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sha256": "1378598b848b6e444d643deb72f7403281d548885557ba819822d838a8148d81",
|
||||||
|
"count": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
// Input contract: 0.1.0 (sha256:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
// Input contract: 0.1.0 (sha256:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
// Input contract: 0.1.0 (sha256:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
@@ -25,7 +25,7 @@ export type CompletionEntry = { description?: string | null; invocation?: Featur
|
|||||||
|
|
||||||
export type CompletionKind = "file" | "feature" | "feature_argument";
|
export type CompletionKind = "file" | "feature" | "feature_argument";
|
||||||
|
|
||||||
export type CreateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; expected_binding_id?: string | null; public_bundle: RuntimePublicIdentityBundle; workspace_trust_id: string; };
|
export type CreateRemoteRuntimeRequest = { display_name?: string | null; endpoint: string; expected_binding_id?: string | null; public_bundle: RuntimePublicIdentityBundle; };
|
||||||
|
|
||||||
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
|
export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; };
|
||||||
|
|
||||||
@@ -77,11 +77,9 @@ export type RuntimeConfigBundleResult = { availability?: unknown; diagnostics?:
|
|||||||
|
|
||||||
export type RuntimeConfigBundleSyncRequest = { bundle: unknown; };
|
export type RuntimeConfigBundleSyncRequest = { bundle: unknown; };
|
||||||
|
|
||||||
export type RuntimeConnectionDisplayState = "configured" | "verified" | "unavailable" | "revoked";
|
|
||||||
|
|
||||||
export type RuntimeConnectionTestFailureKind = "authentication" | "authorization" | "network_unreachable" | "timeout" | "tls_or_transport" | "malformed_response" | "protocol_version_mismatch" | "runtime_identity_mismatch" | "configuration";
|
export type RuntimeConnectionTestFailureKind = "authentication" | "authorization" | "network_unreachable" | "timeout" | "tls_or_transport" | "malformed_response" | "protocol_version_mismatch" | "runtime_identity_mismatch" | "configuration";
|
||||||
|
|
||||||
export type RuntimeConnectionTestResponse = { actual_protocol_version?: number | null; binding_id: string; checked_at: string; connection_state: RuntimeConnectionDisplayState; diagnostics?: Array<Diagnostic>; expected_protocol_version: number; failure_kind?: RuntimeConnectionTestFailureKind | null; runtime_id: string; status: RuntimeConnectionTestStatus; verification?: RuntimeVerificationEvidenceSummary | null; workspace_id: string; };
|
export type RuntimeConnectionTestResponse = { actual_protocol_version?: number | null; binding_id: string; checked_at: string; diagnostics?: Array<Diagnostic>; expected_protocol_version: number; failure_kind?: RuntimeConnectionTestFailureKind | null; runtime_id: string; status: RuntimeConnectionTestStatus; workspace_id: string; };
|
||||||
|
|
||||||
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
||||||
|
|
||||||
@@ -121,10 +119,6 @@ export type RuntimeTrustKeyState = { binding_id?: string | null; created_at?: st
|
|||||||
|
|
||||||
export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
|
export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
|
||||||
|
|
||||||
export type RuntimeVerificationEvidenceSummary = { binding_id: string; last_checked_at: string; last_outcome: RuntimeVerificationOutcome; runtime_public_key_fingerprint: string; verified_at?: string | null; workspace_key_id: string; workspace_public_key_fingerprint: string; workspace_trust_id: string; };
|
|
||||||
|
|
||||||
export type RuntimeVerificationOutcome = "verified" | "challenge_issued" | "verification_failed" | "connectivity_failed";
|
|
||||||
|
|
||||||
export type RuntimeWorkerCompletionsRequest = { context?: CompletionContext | null; kind: CompletionKind; prefix?: string; request_id?: string | null; };
|
export type RuntimeWorkerCompletionsRequest = { context?: CompletionContext | null; kind: CompletionKind; prefix?: string; request_id?: string | null; };
|
||||||
|
|
||||||
export type RuntimeWorkerCompletionsResult = { context?: CompletionContext | null; diagnostics?: Array<Diagnostic>; entries: Array<CompletionEntry>; kind: CompletionKind; prefix: string; request_id?: string | null; runtime_id: string; worker_id: string; };
|
export type RuntimeWorkerCompletionsResult = { context?: CompletionContext | null; diagnostics?: Array<Diagnostic>; entries: Array<CompletionEntry>; kind: CompletionKind; prefix: string; request_id?: string | null; runtime_id: string; worker_id: string; };
|
||||||
@@ -217,9 +211,7 @@ export type WorkingDirectorySummary = { cleanliness?: string | null; cleanup_tar
|
|||||||
|
|
||||||
export type WorkspaceConfigAccess = "read_only" | "read_write";
|
export type WorkspaceConfigAccess = "read_only" | "read_write";
|
||||||
|
|
||||||
export type WorkspaceRuntimeBindingState = "configured" | "verified" | "revoked";
|
export type WorkspaceRuntimeBindingSummary = { binding_id: string; revoked_at?: string | null; };
|
||||||
|
|
||||||
export type WorkspaceRuntimeBindingSummary = { binding_id: string; connection_state: RuntimeConnectionDisplayState; state: WorkspaceRuntimeBindingState; verification?: RuntimeVerificationEvidenceSummary | null; workspace_key_id?: string | null; workspace_trust_id?: string | null; };
|
|
||||||
|
|
||||||
export type WorkspaceRuntimeDetail = { endpoint?: string | null; recent_audit?: Array<RuntimeTrustAuditEntry>; runtime: WorkspaceRuntimeResource; trust_key: RuntimeTrustKeyState; workspace_id: string; };
|
export type WorkspaceRuntimeDetail = { endpoint?: string | null; recent_audit?: Array<RuntimeTrustAuditEntry>; runtime: WorkspaceRuntimeResource; trust_key: RuntimeTrustKeyState; workspace_id: string; };
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
// Input contract: 0.1.0 (sha256:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
// Input contract: 0.1.0 (sha256:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
|
||||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
// Input contract: 0.1.0 (sha256:2e8dcd75d5d7b70c5d820999afcabf3d1377896732c7a3e8c3839e73a994974c)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
@@ -2,15 +2,12 @@ import type {
|
|||||||
Diagnostic,
|
Diagnostic,
|
||||||
RuntimeConnectionTestFailureKind,
|
RuntimeConnectionTestFailureKind,
|
||||||
RuntimeConnectionTestResponse,
|
RuntimeConnectionTestResponse,
|
||||||
RuntimeVerificationEvidenceSummary,
|
|
||||||
} from "#lib/generated/runtime-api.ts";
|
} from "#lib/generated/runtime-api.ts";
|
||||||
|
|
||||||
const RESPONSE_KEYS = [
|
const RESPONSE_KEYS = [
|
||||||
"workspace_id",
|
"workspace_id",
|
||||||
"runtime_id",
|
"runtime_id",
|
||||||
"binding_id",
|
"binding_id",
|
||||||
"connection_state",
|
|
||||||
"verification",
|
|
||||||
"checked_at",
|
"checked_at",
|
||||||
"status",
|
"status",
|
||||||
"failure_kind",
|
"failure_kind",
|
||||||
@@ -108,27 +105,11 @@ export function parseRuntimeConnectionTestResponse(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const bindingId = value.binding_id;
|
const bindingId = value.binding_id;
|
||||||
const connectionState = parseConnectionState(value.connection_state);
|
if (!isBoundedString(bindingId, 256)) return null;
|
||||||
const verification = parseVerificationEvidence(value.verification);
|
|
||||||
if (
|
|
||||||
!isBoundedString(bindingId) ||
|
|
||||||
connectionState === null ||
|
|
||||||
(value.verification !== null && verification === null) ||
|
|
||||||
(verification !== null &&
|
|
||||||
verification.binding_id !== bindingId) ||
|
|
||||||
(connectionState === "verified" && value.status !== "compatible") ||
|
|
||||||
(connectionState === "verified" && verification !== null &&
|
|
||||||
(verification.last_outcome !== "verified" ||
|
|
||||||
verification.verified_at === null))
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
workspace_id: value.workspace_id,
|
workspace_id: value.workspace_id,
|
||||||
runtime_id: value.runtime_id,
|
runtime_id: value.runtime_id,
|
||||||
binding_id: bindingId,
|
binding_id: bindingId,
|
||||||
connection_state: connectionState,
|
|
||||||
verification,
|
|
||||||
checked_at: value.checked_at,
|
checked_at: value.checked_at,
|
||||||
status: value.status,
|
status: value.status,
|
||||||
failure_kind: failureKind as RuntimeConnectionTestFailureKind | null,
|
failure_kind: failureKind as RuntimeConnectionTestFailureKind | null,
|
||||||
@@ -138,60 +119,6 @@ export function parseRuntimeConnectionTestResponse(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseConnectionState(
|
|
||||||
value: unknown,
|
|
||||||
): "configured" | "verified" | "unavailable" | "revoked" | null {
|
|
||||||
return value === "configured" || value === "verified" ||
|
|
||||||
value === "unavailable" || value === "revoked"
|
|
||||||
? value
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseVerificationEvidence(
|
|
||||||
value: unknown,
|
|
||||||
): RuntimeVerificationEvidenceSummary | null {
|
|
||||||
if (value === null) return null;
|
|
||||||
const keys = [
|
|
||||||
"verified_at",
|
|
||||||
"last_checked_at",
|
|
||||||
"last_outcome",
|
|
||||||
"binding_id",
|
|
||||||
"workspace_key_id",
|
|
||||||
"workspace_public_key_fingerprint",
|
|
||||||
"workspace_trust_id",
|
|
||||||
"runtime_public_key_fingerprint",
|
|
||||||
] as const;
|
|
||||||
if (!isRecord(value) || !hasExactKeys(value, keys)) return null;
|
|
||||||
if (
|
|
||||||
(value.verified_at !== null &&
|
|
||||||
(!isBoundedString(value.verified_at, 128) ||
|
|
||||||
Number.isNaN(Date.parse(value.verified_at)))) ||
|
|
||||||
!isBoundedString(value.last_checked_at, 128) ||
|
|
||||||
Number.isNaN(Date.parse(value.last_checked_at)) ||
|
|
||||||
(value.last_outcome !== "verified" &&
|
|
||||||
value.last_outcome !== "challenge_issued" &&
|
|
||||||
value.last_outcome !== "verification_failed" &&
|
|
||||||
value.last_outcome !== "connectivity_failed") ||
|
|
||||||
!isBoundedString(value.binding_id) ||
|
|
||||||
!isBoundedString(value.workspace_key_id, 128) ||
|
|
||||||
!isBoundedString(value.workspace_public_key_fingerprint) ||
|
|
||||||
!isBoundedString(value.workspace_trust_id) ||
|
|
||||||
!isBoundedString(value.runtime_public_key_fingerprint, 128)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
verified_at: value.verified_at,
|
|
||||||
last_checked_at: value.last_checked_at,
|
|
||||||
last_outcome: value.last_outcome,
|
|
||||||
binding_id: value.binding_id,
|
|
||||||
workspace_key_id: value.workspace_key_id,
|
|
||||||
workspace_public_key_fingerprint: value.workspace_public_key_fingerprint,
|
|
||||||
workspace_trust_id: value.workspace_trust_id,
|
|
||||||
runtime_public_key_fingerprint: value.runtime_public_key_fingerprint,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function testRuntimeConnection(
|
export async function testRuntimeConnection(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
runtimeId: string,
|
runtimeId: string,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import type {
|
|||||||
Diagnostic,
|
Diagnostic,
|
||||||
RemoveRuntimeRequest,
|
RemoveRuntimeRequest,
|
||||||
RevokeRuntimeTrustKeyRequest,
|
RevokeRuntimeTrustKeyRequest,
|
||||||
RuntimeConnectionDisplayState,
|
|
||||||
RuntimeIdentityAuthority,
|
RuntimeIdentityAuthority,
|
||||||
RuntimeManagementApiError,
|
RuntimeManagementApiError,
|
||||||
RuntimeManagementSummary,
|
RuntimeManagementSummary,
|
||||||
@@ -17,14 +16,13 @@ import type {
|
|||||||
RuntimeTrustKeyRevealResponse,
|
RuntimeTrustKeyRevealResponse,
|
||||||
RuntimeTrustKeyState,
|
RuntimeTrustKeyState,
|
||||||
RuntimeTrustKeyStatus,
|
RuntimeTrustKeyStatus,
|
||||||
RuntimeVerificationEvidenceSummary,
|
|
||||||
UpdateRemoteRuntimeRequest,
|
UpdateRemoteRuntimeRequest,
|
||||||
WorkspaceRuntimeBindingState,
|
|
||||||
WorkspaceRuntimeBindingSummary,
|
WorkspaceRuntimeBindingSummary,
|
||||||
WorkspaceRuntimeDetail,
|
WorkspaceRuntimeDetail,
|
||||||
WorkspaceRuntimeListResponse,
|
WorkspaceRuntimeListResponse,
|
||||||
WorkspaceRuntimeResource,
|
WorkspaceRuntimeResource,
|
||||||
} from "#lib/generated/runtime-api.ts";
|
} from "#lib/generated/runtime-api.ts";
|
||||||
|
import type { RepositoryApiError } from "#lib/generated/repository-api.ts";
|
||||||
import { workspaceApiPath } from "./http.ts";
|
import { workspaceApiPath } from "./http.ts";
|
||||||
|
|
||||||
export type WorkspaceRuntimeList =
|
export type WorkspaceRuntimeList =
|
||||||
@@ -90,17 +88,6 @@ const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
|
|||||||
"stale_binding",
|
"stale_binding",
|
||||||
"fingerprint_in_use",
|
"fingerprint_in_use",
|
||||||
]);
|
]);
|
||||||
const BINDING_STATES = new Set<WorkspaceRuntimeBindingState>([
|
|
||||||
"configured",
|
|
||||||
"verified",
|
|
||||||
"revoked",
|
|
||||||
]);
|
|
||||||
const CONNECTION_STATES = new Set<RuntimeConnectionDisplayState>([
|
|
||||||
"configured",
|
|
||||||
"verified",
|
|
||||||
"unavailable",
|
|
||||||
"revoked",
|
|
||||||
]);
|
|
||||||
const REMOVAL_OPERATION_STATES = new Set<RuntimeRemovalOperationState>([
|
const REMOVAL_OPERATION_STATES = new Set<RuntimeRemovalOperationState>([
|
||||||
"pending",
|
"pending",
|
||||||
"cleanup_pending",
|
"cleanup_pending",
|
||||||
@@ -326,140 +313,16 @@ function runtimeSource(value: unknown, path: string): RuntimeSourceSummary {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function runtimeVerification(
|
|
||||||
value: unknown,
|
|
||||||
path: string,
|
|
||||||
): RuntimeVerificationEvidenceSummary {
|
|
||||||
const item = object(value, path);
|
|
||||||
exactKeys(
|
|
||||||
item,
|
|
||||||
[
|
|
||||||
"verified_at",
|
|
||||||
"last_checked_at",
|
|
||||||
"last_outcome",
|
|
||||||
"binding_id",
|
|
||||||
"workspace_key_id",
|
|
||||||
"workspace_public_key_fingerprint",
|
|
||||||
"workspace_trust_id",
|
|
||||||
"runtime_public_key_fingerprint",
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
path,
|
|
||||||
);
|
|
||||||
const verifiedAt = item.verified_at === null
|
|
||||||
? null
|
|
||||||
: boundedString(item.verified_at, `${path}.verified_at`, 128);
|
|
||||||
const lastOutcome = enumValue(
|
|
||||||
item.last_outcome,
|
|
||||||
`${path}.last_outcome`,
|
|
||||||
new Set(
|
|
||||||
[
|
|
||||||
"verified",
|
|
||||||
"challenge_issued",
|
|
||||||
"verification_failed",
|
|
||||||
"connectivity_failed",
|
|
||||||
] as const,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
verified_at: verifiedAt,
|
|
||||||
last_checked_at: boundedString(
|
|
||||||
item.last_checked_at,
|
|
||||||
`${path}.last_checked_at`,
|
|
||||||
128,
|
|
||||||
),
|
|
||||||
last_outcome: lastOutcome,
|
|
||||||
binding_id: boundedIdentity(
|
|
||||||
item.binding_id,
|
|
||||||
`${path}.binding_id`,
|
|
||||||
),
|
|
||||||
workspace_key_id: boundedString(
|
|
||||||
item.workspace_key_id,
|
|
||||||
`${path}.workspace_key_id`,
|
|
||||||
LIMITS.idBytes,
|
|
||||||
),
|
|
||||||
workspace_public_key_fingerprint: boundedIdentity(
|
|
||||||
item.workspace_public_key_fingerprint,
|
|
||||||
`${path}.workspace_public_key_fingerprint`,
|
|
||||||
),
|
|
||||||
workspace_trust_id: boundedIdentity(
|
|
||||||
item.workspace_trust_id,
|
|
||||||
`${path}.workspace_trust_id`,
|
|
||||||
),
|
|
||||||
runtime_public_key_fingerprint: boundedString(
|
|
||||||
item.runtime_public_key_fingerprint,
|
|
||||||
`${path}.runtime_public_key_fingerprint`,
|
|
||||||
LIMITS.fingerprintBytes,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function runtimeBinding(
|
function runtimeBinding(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
path: string,
|
path: string,
|
||||||
requiresWorkspaceIdentity: boolean,
|
|
||||||
): WorkspaceRuntimeBindingSummary {
|
): WorkspaceRuntimeBindingSummary {
|
||||||
const item = object(value, path);
|
const item = object(value, path);
|
||||||
exactKeys(
|
exactKeys(item, ["binding_id"], ["revoked_at"], path);
|
||||||
item,
|
|
||||||
["state", "connection_state", "binding_id"],
|
|
||||||
["workspace_key_id", "workspace_trust_id", "verification"],
|
|
||||||
path,
|
|
||||||
);
|
|
||||||
const workspaceKeyId = optionalNullableString(
|
|
||||||
item.workspace_key_id,
|
|
||||||
`${path}.workspace_key_id`,
|
|
||||||
LIMITS.idBytes,
|
|
||||||
);
|
|
||||||
const workspaceTrustId = optionalNullableIdentity(
|
|
||||||
item.workspace_trust_id,
|
|
||||||
`${path}.workspace_trust_id`,
|
|
||||||
);
|
|
||||||
const state = enumValue(item.state, `${path}.state`, BINDING_STATES);
|
|
||||||
if (
|
|
||||||
requiresWorkspaceIdentity &&
|
|
||||||
state !== "revoked" &&
|
|
||||||
(workspaceKeyId == null || workspaceTrustId == null)
|
|
||||||
) {
|
|
||||||
return fail(path, "requires Workspace signing key identity metadata");
|
|
||||||
}
|
|
||||||
const connectionState = enumValue(
|
|
||||||
item.connection_state,
|
|
||||||
`${path}.connection_state`,
|
|
||||||
CONNECTION_STATES,
|
|
||||||
);
|
|
||||||
const binding_id = boundedIdentity(item.binding_id, `${path}.binding_id`);
|
|
||||||
const verification = item.verification === undefined
|
|
||||||
? undefined
|
|
||||||
: runtimeVerification(item.verification, `${path}.verification`);
|
|
||||||
if (
|
|
||||||
verification !== undefined && verification.binding_id !== binding_id
|
|
||||||
) {
|
|
||||||
return fail(path, "verification must match the current binding binding_id");
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
requiresWorkspaceIdentity &&
|
|
||||||
connectionState === "verified" &&
|
|
||||||
(verification === undefined ||
|
|
||||||
verification.verified_at === null ||
|
|
||||||
verification.last_outcome !== "verified")
|
|
||||||
) {
|
|
||||||
return fail(
|
|
||||||
path,
|
|
||||||
"verified Workspace identity binding requires verification evidence",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
state,
|
binding_id: boundedIdentity(item.binding_id, `${path}.binding_id`),
|
||||||
connection_state: connectionState,
|
revoked_at:
|
||||||
binding_id,
|
optionalNullableTimestamp(item.revoked_at, `${path}.revoked_at`) ?? null,
|
||||||
...(workspaceKeyId === undefined
|
|
||||||
? {}
|
|
||||||
: { workspace_key_id: workspaceKeyId }),
|
|
||||||
...(workspaceTrustId === undefined
|
|
||||||
? {}
|
|
||||||
: { workspace_trust_id: workspaceTrustId }),
|
|
||||||
...(verification === undefined ? {} : { verification }),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -483,7 +346,7 @@ function runtimeManagement(
|
|||||||
const builtIn = boolean(item.built_in, `${path}.built_in`);
|
const builtIn = boolean(item.built_in, `${path}.built_in`);
|
||||||
const binding = item.binding == null
|
const binding = item.binding == null
|
||||||
? undefined
|
? undefined
|
||||||
: runtimeBinding(item.binding, `${path}.binding`, !builtIn);
|
: runtimeBinding(item.binding, `${path}.binding`);
|
||||||
return {
|
return {
|
||||||
built_in: builtIn,
|
built_in: builtIn,
|
||||||
config_managed: boolean(item.config_managed, `${path}.config_managed`),
|
config_managed: boolean(item.config_managed, `${path}.config_managed`),
|
||||||
@@ -763,7 +626,7 @@ export function parseRuntimeTrustConflict(
|
|||||||
exactKeys(
|
exactKeys(
|
||||||
response,
|
response,
|
||||||
["error", "message"],
|
["error", "message"],
|
||||||
["current_binding_id", "current_fingerprint"],
|
["current_binding_id", "current_fingerprint", "diagnostics"],
|
||||||
"Runtime trust conflict",
|
"Runtime trust conflict",
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -786,6 +649,15 @@ export function parseRuntimeTrustConflict(
|
|||||||
"Runtime trust conflict.current_fingerprint",
|
"Runtime trust conflict.current_fingerprint",
|
||||||
LIMITS.fingerprintBytes,
|
LIMITS.fingerprintBytes,
|
||||||
),
|
),
|
||||||
|
...(response.diagnostics === undefined ? {} : {
|
||||||
|
diagnostics: array(
|
||||||
|
response.diagnostics,
|
||||||
|
"Runtime trust conflict.diagnostics",
|
||||||
|
LIMITS.diagnostics,
|
||||||
|
).map((entry, index) =>
|
||||||
|
diagnostic(entry, `Runtime trust conflict.diagnostics[${index}]`)
|
||||||
|
),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -891,6 +763,37 @@ async function readBoundedJson(response: Response): Promise<unknown> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function repositoryApiError(value: unknown): RepositoryApiError {
|
||||||
|
const response = object(value, "Runtime trust error");
|
||||||
|
exactKeys(
|
||||||
|
response,
|
||||||
|
["error", "message"],
|
||||||
|
["diagnostics"],
|
||||||
|
"Runtime trust error",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
error: boundedString(
|
||||||
|
response.error,
|
||||||
|
"Runtime trust error.error",
|
||||||
|
LIMITS.idBytes,
|
||||||
|
),
|
||||||
|
message: boundedString(
|
||||||
|
response.message,
|
||||||
|
"Runtime trust error.message",
|
||||||
|
LIMITS.conflictMessageBytes,
|
||||||
|
),
|
||||||
|
...(response.diagnostics === undefined ? {} : {
|
||||||
|
diagnostics: array(
|
||||||
|
response.diagnostics,
|
||||||
|
"Runtime trust error.diagnostics",
|
||||||
|
LIMITS.diagnostics,
|
||||||
|
).map((entry, index) =>
|
||||||
|
diagnostic(entry, `Runtime trust error.diagnostics[${index}]`)
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function requestErrorFrom(
|
function requestErrorFrom(
|
||||||
value: unknown,
|
value: unknown,
|
||||||
status: number,
|
status: number,
|
||||||
@@ -917,30 +820,13 @@ function requestErrorFrom(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
exactKeys(
|
const error = repositoryApiError(response);
|
||||||
response,
|
const field = (error.diagnostics ?? []).some((entry) =>
|
||||||
["error", "message", "diagnostics"],
|
|
||||||
[],
|
|
||||||
"Runtime trust error",
|
|
||||||
);
|
|
||||||
const diagnostics = array(
|
|
||||||
response.diagnostics,
|
|
||||||
"Runtime trust error.diagnostics",
|
|
||||||
LIMITS.diagnostics,
|
|
||||||
).map((entry, index) =>
|
|
||||||
diagnostic(entry, `Runtime trust error.diagnostics[${index}]`)
|
|
||||||
);
|
|
||||||
const message = boundedString(
|
|
||||||
response.message,
|
|
||||||
"Runtime trust error.message",
|
|
||||||
LIMITS.conflictMessageBytes,
|
|
||||||
);
|
|
||||||
const field = diagnostics.some((entry) =>
|
|
||||||
entry.code.startsWith("runtime_public_key_")
|
entry.code.startsWith("runtime_public_key_")
|
||||||
)
|
)
|
||||||
? "public_key"
|
? "public_key"
|
||||||
: null;
|
: null;
|
||||||
return new RuntimeTrustRequestError(message, field);
|
return new RuntimeTrustRequestError(error.message, field);
|
||||||
} catch {
|
} catch {
|
||||||
return new RuntimeTrustRequestError(
|
return new RuntimeTrustRequestError(
|
||||||
`Runtime trust request failed (${status})`,
|
`Runtime trust request failed (${status})`,
|
||||||
@@ -960,6 +846,21 @@ function throwRuntimeTrustConflict(payload: unknown): never {
|
|||||||
throw new RuntimeTrustConflictError(conflict);
|
throw new RuntimeTrustConflictError(conflict);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function throwRuntimeCreationConflict(payload: unknown): never {
|
||||||
|
let error: RepositoryApiError;
|
||||||
|
try {
|
||||||
|
error = repositoryApiError(payload);
|
||||||
|
} catch {
|
||||||
|
throw new RuntimeTrustRequestError(
|
||||||
|
"Runtime trust conflict response was invalid",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (CONFLICT_KINDS.has(error.error as RuntimeTrustConflictKind)) {
|
||||||
|
throwRuntimeTrustConflict(error);
|
||||||
|
}
|
||||||
|
throw requestErrorFrom(error, 409);
|
||||||
|
}
|
||||||
|
|
||||||
async function finishMutation(
|
async function finishMutation(
|
||||||
response: Response,
|
response: Response,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
@@ -990,17 +891,16 @@ export async function createRemoteRuntime(
|
|||||||
request: CreateRemoteRuntimeRequest,
|
request: CreateRemoteRuntimeRequest,
|
||||||
fetchImpl: typeof fetch = fetch,
|
fetchImpl: typeof fetch = fetch,
|
||||||
): Promise<WorkspaceRuntimeResource> {
|
): Promise<WorkspaceRuntimeResource> {
|
||||||
const enrollmentId = boundedIdentity(request.workspace_trust_id, "workspace_trust_id");
|
|
||||||
const response = await fetchImpl(
|
const response = await fetchImpl(
|
||||||
workspaceApiPath(workspaceId, "/runtimes"),
|
workspaceApiPath(workspaceId, "/runtimes"),
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ ...request, workspace_trust_id: enrollmentId }),
|
body: JSON.stringify(request),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const payload = await readBoundedJson(response);
|
const payload = await readBoundedJson(response);
|
||||||
if (response.status === 409) throwRuntimeTrustConflict(payload);
|
if (response.status === 409) throwRuntimeCreationConflict(payload);
|
||||||
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
||||||
const runtime = runtimeResource(payload, "Runtime create response");
|
const runtime = runtimeResource(payload, "Runtime create response");
|
||||||
if (runtime.runtime_id !== request.public_bundle.identity_id) {
|
if (runtime.runtime_id !== request.public_bundle.identity_id) {
|
||||||
|
|||||||
@@ -68,6 +68,6 @@ export function repositorySshProbeRuntimes(
|
|||||||
runtime.management.endpoint_configured &&
|
runtime.management.endpoint_configured &&
|
||||||
runtime.management.binding !== undefined &&
|
runtime.management.binding !== undefined &&
|
||||||
runtime.management.binding !== null &&
|
runtime.management.binding !== null &&
|
||||||
runtime.management.binding.state !== "revoked"
|
runtime.management.binding.revoked_at == null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
let runtimePublicBundle = $state('');
|
let runtimePublicBundle = $state('');
|
||||||
let workspaceTrustId = $state('');
|
|
||||||
let displayName = $state('');
|
let displayName = $state('');
|
||||||
let endpoint = $state('');
|
let endpoint = $state('');
|
||||||
let runtimeFingerprint = $state<string | null>(null);
|
let runtimeFingerprint = $state<string | null>(null);
|
||||||
@@ -36,9 +35,7 @@
|
|||||||
|
|
||||||
function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
|
function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
|
||||||
if (result.status === 'compatible') {
|
if (result.status === 'compatible') {
|
||||||
return result.connection_state === 'verified'
|
return `Signed ping succeeded · protocol v${result.actual_protocol_version}`;
|
||||||
? `Verified · protocol v${result.actual_protocol_version}`
|
|
||||||
: `Compatible · ${result.connection_state} · protocol v${result.actual_protocol_version}`;
|
|
||||||
}
|
}
|
||||||
switch (result.failure_kind) {
|
switch (result.failure_kind) {
|
||||||
case 'authentication': return 'Authentication failed';
|
case 'authentication': return 'Authentication failed';
|
||||||
@@ -145,18 +142,16 @@
|
|||||||
}
|
}
|
||||||
await createRemoteRuntime(data.workspaceId, {
|
await createRemoteRuntime(data.workspaceId, {
|
||||||
public_bundle: publicBundle,
|
public_bundle: publicBundle,
|
||||||
workspace_trust_id: workspaceTrustId,
|
|
||||||
display_name: displayName || null,
|
display_name: displayName || null,
|
||||||
endpoint,
|
endpoint,
|
||||||
expected_binding_id: null,
|
expected_binding_id: null,
|
||||||
});
|
});
|
||||||
runtimePublicBundle = '';
|
runtimePublicBundle = '';
|
||||||
workspaceTrustId = '';
|
|
||||||
runtimeFingerprint = null;
|
runtimeFingerprint = null;
|
||||||
displayName = '';
|
displayName = '';
|
||||||
endpoint = '';
|
endpoint = '';
|
||||||
showAddRuntime = false;
|
showAddRuntime = false;
|
||||||
requestNotice = 'Runtime registered for this Workspace. Run Test to complete authenticated verification.';
|
requestNotice = 'Runtime registered for this Workspace. Run Test to observe a signed ping.';
|
||||||
await invalidateAll();
|
await invalidateAll();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
|
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
|
||||||
@@ -171,7 +166,8 @@
|
|||||||
runtime: WorkspaceRuntimeResource,
|
runtime: WorkspaceRuntimeResource,
|
||||||
): RuntimeConnectionTestResponse | undefined {
|
): RuntimeConnectionTestResponse | undefined {
|
||||||
const result = testResults[runtime.runtime_id];
|
const result = testResults[runtime.runtime_id];
|
||||||
return result?.binding_id === runtime.management?.binding?.binding_id
|
return result?.workspace_id === data.workspaceId &&
|
||||||
|
result?.binding_id === runtime.management?.binding?.binding_id
|
||||||
? result
|
? result
|
||||||
: undefined;
|
: undefined;
|
||||||
}
|
}
|
||||||
@@ -179,12 +175,16 @@
|
|||||||
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
|
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
|
||||||
const bindingId = runtime.management?.binding?.binding_id;
|
const bindingId = runtime.management?.binding?.binding_id;
|
||||||
if (typeof bindingId !== 'string') return;
|
if (typeof bindingId !== 'string') return;
|
||||||
|
const workspaceId = data.workspaceId;
|
||||||
const generation = ++connectionTestGeneration;
|
const generation = ++connectionTestGeneration;
|
||||||
requestError = null;
|
requestError = null;
|
||||||
|
const { [runtime.runtime_id]: _previousResult, ...remainingResults } = testResults;
|
||||||
|
testResults = remainingResults;
|
||||||
busyRuntimeId = runtime.runtime_id;
|
busyRuntimeId = runtime.runtime_id;
|
||||||
try {
|
try {
|
||||||
const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id);
|
const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id);
|
||||||
if (
|
if (
|
||||||
|
workspaceId !== data.workspaceId ||
|
||||||
generation !== connectionTestGeneration ||
|
generation !== connectionTestGeneration ||
|
||||||
result.binding_id !== bindingId
|
result.binding_id !== bindingId
|
||||||
) {
|
) {
|
||||||
@@ -261,15 +261,9 @@
|
|||||||
</button>
|
</button>
|
||||||
<pre>yoi-runtime trust-workspace add --bundle {workspaceBundleFilename()}</pre>
|
<pre>yoi-runtime trust-workspace add --bundle {workspaceBundleFilename()}</pre>
|
||||||
<small>
|
<small>
|
||||||
Copy the Runtime-issued <code>workspace_trust_id</code> from <code>trust-workspace add/show</code>.
|
|
||||||
It identifies this enrollment, not the Workspace key fingerprint.
|
|
||||||
Pass the same <code>--fs-root</code> and <code>--fs-runtime-dir</code> options used by the Runtime
|
Pass the same <code>--fs-root</code> and <code>--fs-runtime-dir</code> options used by the Runtime
|
||||||
service. Existing Workspace trust entries are preserved.
|
service. Existing Workspace trust entries are preserved.
|
||||||
</small>
|
</small>
|
||||||
<label>
|
|
||||||
Workspace trust ID
|
|
||||||
<input bind:value={workspaceTrustId} required autocomplete="off" spellcheck="false" />
|
|
||||||
</label>
|
|
||||||
{:else}
|
{:else}
|
||||||
<p class="section-state">Loading Workspace public identity…</p>
|
<p class="section-state">Loading Workspace public identity…</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -323,14 +317,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Registration stores this Workspace-scoped binding. After it appears in the list, run
|
Registration stores this Workspace-scoped binding. After it appears in the list, run
|
||||||
<strong>Test</strong> to complete authenticated verification.
|
<strong>Test</strong> to observe a signed ping. Each request authenticates independently.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="settings-action-row">
|
<div class="settings-action-row">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busyRuntimeId !== null || !data.signingIdentity?.public_bundle || !runtimeFingerprint || !workspaceTrustId}
|
disabled={busyRuntimeId !== null || !runtimeFingerprint}
|
||||||
>Register Runtime</button>
|
>Register Runtime</button>
|
||||||
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
|
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
|
||||||
Cancel
|
Cancel
|
||||||
@@ -368,7 +362,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{#each data.runtimes.items as runtime}
|
{#each data.runtimes.items as runtime}
|
||||||
<tr class:inactive={runtime.status !== 'active'}>
|
<tr class:inactive={Boolean(runtime.management?.binding?.revoked_at) || currentTestResult(runtime)?.status === 'failed'}>
|
||||||
<td>
|
<td>
|
||||||
<strong>
|
<strong>
|
||||||
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}`}>
|
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}`}>
|
||||||
@@ -379,7 +373,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>{runtime.kind}</td>
|
<td>{runtime.kind}</td>
|
||||||
<td>
|
<td>
|
||||||
{runtime.management?.binding?.connection_state ?? runtime.status}
|
{runtime.management?.binding?.revoked_at ? 'Revoked' : currentTestResult(runtime) ? connectionTestSummary(currentTestResult(runtime)!) : 'Not tested'}
|
||||||
</td>
|
</td>
|
||||||
<td>{runtimePlatform(runtime)}</td>
|
<td>{runtimePlatform(runtime)}</td>
|
||||||
<td>{managementLabel(runtime)}</td>
|
<td>{managementLabel(runtime)}</td>
|
||||||
@@ -390,16 +384,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div class="settings-action-row">
|
<div class="settings-action-row">
|
||||||
{#if runtime.management?.config_managed && runtime.management.binding?.connection_state !== 'revoked'}
|
{#if runtime.management?.config_managed && !runtime.management.binding?.revoked_at}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={busyRuntimeId !== null}
|
disabled={busyRuntimeId !== null}
|
||||||
onclick={() => testRuntime(runtime)}
|
onclick={() => testRuntime(runtime)}
|
||||||
>Test</button>
|
>Test</button>
|
||||||
{/if}
|
{/if}
|
||||||
{#if runtime.management?.binding?.state === 'configured'}
|
{#if !runtime.management?.config_managed}
|
||||||
<span class="settings-muted-action">Verification required</span>
|
|
||||||
{:else if !runtime.management?.config_managed}
|
|
||||||
<span class="settings-muted-action">Test unavailable</span>
|
<span class="settings-muted-action">Test unavailable</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+53
-33
@@ -2,6 +2,7 @@
|
|||||||
import { goto, invalidateAll } from '$app/navigation';
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
import type {
|
import type {
|
||||||
RevokeRuntimeTrustKeyRequest,
|
RevokeRuntimeTrustKeyRequest,
|
||||||
|
RuntimeConnectionTestResponse,
|
||||||
RuntimeTrustKeyStatus,
|
RuntimeTrustKeyStatus,
|
||||||
} from '#lib/generated/runtime-api.ts';
|
} from '#lib/generated/runtime-api.ts';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +18,7 @@
|
|||||||
RuntimeTrustRequestError,
|
RuntimeTrustRequestError,
|
||||||
type RuntimeTrustRouteOperation,
|
type RuntimeTrustRouteOperation,
|
||||||
} from '#lib/workspace/api/runtime-management.ts';
|
} from '#lib/workspace/api/runtime-management.ts';
|
||||||
|
import { testRuntimeConnection } from '#lib/workspace/api/runtime-connection.ts';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
type TrustAction = 'create' | 'replace' | 'reactivate';
|
type TrustAction = 'create' | 'replace' | 'reactivate';
|
||||||
@@ -25,18 +27,19 @@
|
|||||||
let showPublicKey = $state(false);
|
let showPublicKey = $state(false);
|
||||||
let revealedPublicKey = $state<string | null>(null);
|
let revealedPublicKey = $state<string | null>(null);
|
||||||
let publicKey = $state('');
|
let publicKey = $state('');
|
||||||
let workspaceTrustId = $state('');
|
|
||||||
let displayName = $state('');
|
let displayName = $state('');
|
||||||
let endpoint = $state('');
|
let endpoint = $state('');
|
||||||
let editingMetadata = $state(false);
|
let editingMetadata = $state(false);
|
||||||
let deleteRuntimeConfirmation = $state('');
|
let deleteRuntimeConfirmation = $state('');
|
||||||
let busyAction = $state<'metadata' | 'trust' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
|
let busyAction = $state<'metadata' | 'trust' | 'revoke' | 'reveal' | 'copy' | 'delete' | 'test' | null>(null);
|
||||||
let fieldError = $state<string | null>(null);
|
let fieldError = $state<string | null>(null);
|
||||||
let deleteRuntimeError = $state<string | null>(null);
|
let deleteRuntimeError = $state<string | null>(null);
|
||||||
let requestError = $state<string | null>(null);
|
let requestError = $state<string | null>(null);
|
||||||
let successMessage = $state<string | null>(null);
|
let successMessage = $state<string | null>(null);
|
||||||
let replacementFingerprint = $state<string | null>(null);
|
let replacementFingerprint = $state<string | null>(null);
|
||||||
let replacementFingerprintError = $state<string | null>(null);
|
let replacementFingerprintError = $state<string | null>(null);
|
||||||
|
let connectionResult = $state<RuntimeConnectionTestResponse | null>(null);
|
||||||
|
let connectionError = $state<string | null>(null);
|
||||||
let fingerprintGeneration = 0;
|
let fingerprintGeneration = 0;
|
||||||
const routeFence = new RuntimeTrustRouteFence();
|
const routeFence = new RuntimeTrustRouteFence();
|
||||||
const runtimeRemovalAttempt = new RuntimeRemovalAttempt();
|
const runtimeRemovalAttempt = new RuntimeRemovalAttempt();
|
||||||
@@ -47,10 +50,11 @@
|
|||||||
if (nextGeneration === routeGeneration) return;
|
if (nextGeneration === routeGeneration) return;
|
||||||
routeGeneration = nextGeneration;
|
routeGeneration = nextGeneration;
|
||||||
fingerprintGeneration += 1;
|
fingerprintGeneration += 1;
|
||||||
|
connectionResult = null;
|
||||||
|
connectionError = null;
|
||||||
showPublicKey = false;
|
showPublicKey = false;
|
||||||
revealedPublicKey = null;
|
revealedPublicKey = null;
|
||||||
publicKey = '';
|
publicKey = '';
|
||||||
workspaceTrustId = '';
|
|
||||||
displayName = data.runtimeDetail?.runtime.label ?? '';
|
displayName = data.runtimeDetail?.runtime.label ?? '';
|
||||||
endpoint = data.runtimeDetail?.endpoint ?? '';
|
endpoint = data.runtimeDetail?.endpoint ?? '';
|
||||||
editingMetadata = false;
|
editingMetadata = false;
|
||||||
@@ -109,6 +113,28 @@
|
|||||||
return new TextEncoder().encode(value).byteLength;
|
return new TextEncoder().encode(value).byteLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function testConnection(): Promise<void> {
|
||||||
|
const bindingId = data.runtimeDetail?.runtime.management.binding?.binding_id;
|
||||||
|
if (busyAction !== null || !bindingId) return;
|
||||||
|
const operation = routeFence.capture(data.runtimeId);
|
||||||
|
const workspaceId = data.workspaceId;
|
||||||
|
busyAction = 'test';
|
||||||
|
connectionResult = null;
|
||||||
|
connectionError = null;
|
||||||
|
try {
|
||||||
|
const result = await testRuntimeConnection(workspaceId, operation.runtimeId);
|
||||||
|
if (!isCurrentRoute(operation) || workspaceId !== data.workspaceId ||
|
||||||
|
result.binding_id !== data.runtimeDetail?.runtime.management.binding?.binding_id) return;
|
||||||
|
connectionResult = result;
|
||||||
|
} catch (error) {
|
||||||
|
if (isCurrentRoute(operation) && workspaceId === data.workspaceId) {
|
||||||
|
connectionError = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (isCurrentRoute(operation) && workspaceId === data.workspaceId) busyAction = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function reloadAuthority(): Promise<void> {
|
async function reloadAuthority(): Promise<void> {
|
||||||
await invalidateAll();
|
await invalidateAll();
|
||||||
}
|
}
|
||||||
@@ -195,7 +221,7 @@
|
|||||||
const binding = data.runtimeDetail.runtime.management.binding;
|
const binding = data.runtimeDetail.runtime.management.binding;
|
||||||
if (!binding || !data.runtimeDetail.endpoint) {
|
if (!binding || !data.runtimeDetail.endpoint) {
|
||||||
throw new RuntimeTrustRequestError(
|
throw new RuntimeTrustRequestError(
|
||||||
'The Workspace identity binding and authoritative Runtime endpoint are required.',
|
'The Runtime binding and authoritative endpoint are required.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await createRemoteRuntime(data.workspaceId, {
|
await createRemoteRuntime(data.workspaceId, {
|
||||||
@@ -203,14 +229,12 @@
|
|||||||
identity_id: operation.runtimeId,
|
identity_id: operation.runtimeId,
|
||||||
public_key: key,
|
public_key: key,
|
||||||
},
|
},
|
||||||
workspace_trust_id: workspaceTrustId,
|
|
||||||
display_name: data.runtimeDetail.runtime.label,
|
display_name: data.runtimeDetail.runtime.label,
|
||||||
endpoint: data.runtimeDetail.endpoint,
|
endpoint: data.runtimeDetail.endpoint,
|
||||||
expected_binding_id: binding.binding_id,
|
expected_binding_id: binding.binding_id,
|
||||||
});
|
});
|
||||||
if (!isCurrentRoute(operation)) return;
|
if (!isCurrentRoute(operation)) return;
|
||||||
publicKey = '';
|
publicKey = '';
|
||||||
workspaceTrustId = '';
|
|
||||||
showPublicKey = false;
|
showPublicKey = false;
|
||||||
revealedPublicKey = null;
|
revealedPublicKey = null;
|
||||||
successMessage = action === 'create'
|
successMessage = action === 'create'
|
||||||
@@ -396,8 +420,8 @@
|
|||||||
{@const detail = data.runtimeDetail}
|
{@const detail = data.runtimeDetail}
|
||||||
{@const runtime = detail.runtime}
|
{@const runtime = detail.runtime}
|
||||||
{@const trust = detail.trust_key}
|
{@const trust = detail.trust_key}
|
||||||
{@const verifiedTrust = runtime.management.binding?.state === 'verified'}
|
|
||||||
{@const currentAction = trustAction(trust.status)}
|
{@const currentAction = trustAction(trust.status)}
|
||||||
|
{@const latestResult = connectionResult?.workspace_id === data.workspaceId && connectionResult?.runtime_id === data.runtimeId && connectionResult?.binding_id === runtime.management.binding?.binding_id ? connectionResult : null}
|
||||||
|
|
||||||
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
|
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
|
||||||
<h2 id="runtime-identity-heading">Identity and binding</h2>
|
<h2 id="runtime-identity-heading">Identity and binding</h2>
|
||||||
@@ -405,17 +429,32 @@
|
|||||||
<div><dt>Runtime ID</dt><dd><code>{runtime.runtime_id}</code></dd></div>
|
<div><dt>Runtime ID</dt><dd><code>{runtime.runtime_id}</code></dd></div>
|
||||||
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
|
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
|
||||||
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
|
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
|
||||||
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
|
<div><dt>Connection test</dt><dd>{runtime.management.binding?.revoked_at ? 'Revoked' : latestResult ? latestResult.status === 'compatible' ? 'Signed ping succeeded' : `Signed ping failed · ${latestResult.failure_kind}` : 'Not tested'}</dd></div>
|
||||||
<div><dt>Connection state</dt><dd>{runtime.management.binding?.connection_state ?? 'Not configured'}</dd></div>
|
<div><dt>Binding ID</dt><dd><code>{runtime.management.binding?.binding_id ?? '—'}</code></dd></div>
|
||||||
<div><dt>Workspace signing key</dt><dd><code>{runtime.management.binding?.workspace_key_id ?? '—'}</code></dd></div>
|
|
||||||
<div><dt>Verified</dt><dd>{formatTimestamp(runtime.management.binding?.verification?.verified_at)}</dd></div>
|
|
||||||
<div><dt>Last verification check</dt><dd>{runtime.management.binding?.verification?.last_outcome ?? '—'} · {formatTimestamp(runtime.management.binding?.verification?.last_checked_at)}</dd></div>
|
|
||||||
<div><dt>Runtime key status</dt><dd>{trust.status}</dd></div>
|
<div><dt>Runtime key status</dt><dd>{trust.status}</dd></div>
|
||||||
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
|
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
|
||||||
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
|
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
|
||||||
<div><dt>Updated</dt><dd>{formatTimestamp(trust.updated_at)}</dd></div>
|
<div><dt>Updated</dt><dd>{formatTimestamp(trust.updated_at)}</dd></div>
|
||||||
<div><dt>Revoked</dt><dd>{formatTimestamp(trust.revoked_at)}</dd></div>
|
<div><dt>Revoked</dt><dd>{formatTimestamp(trust.revoked_at)}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
|
{#if runtime.management.config_managed && !runtime.management.binding?.revoked_at}
|
||||||
|
<div class="settings-action-row">
|
||||||
|
<button type="button" disabled={busyAction !== null} onclick={testConnection}>
|
||||||
|
{busyAction === 'test' ? 'Testing…' : 'Test'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if latestResult}
|
||||||
|
<p class="section-state" class:error={latestResult.status === 'failed'}>
|
||||||
|
Checked {formatTimestamp(latestResult.checked_at)}
|
||||||
|
{#each latestResult.diagnostics ?? [] as diagnostic}
|
||||||
|
<span>{diagnostic.message}</span>
|
||||||
|
{/each}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if connectionError}
|
||||||
|
<p class="section-state error" role="alert">{connectionError}</p>
|
||||||
|
{/if}
|
||||||
{#if (runtime.diagnostics ?? []).length > 0}
|
{#if (runtime.diagnostics ?? []).length > 0}
|
||||||
<ul class="settings-diagnostics-list">
|
<ul class="settings-diagnostics-list">
|
||||||
{#each runtime.diagnostics ?? [] as diagnostic}
|
{#each runtime.diagnostics ?? [] as diagnostic}
|
||||||
@@ -495,25 +534,7 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if verifiedTrust}
|
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
||||||
<div class="runtime-public-key-readonly">
|
|
||||||
<strong>Runtime public key</strong>
|
|
||||||
<p>
|
|
||||||
This verified key is read-only. Revoke Workspace trust and register the Runtime again to use a different public key.
|
|
||||||
</p>
|
|
||||||
<code>{trust.fingerprint}</code>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
|
||||||
<label for="runtime-workspace-trust-id-input">Workspace trust ID</label>
|
|
||||||
<input
|
|
||||||
id="runtime-workspace-trust-id-input"
|
|
||||||
bind:value={workspaceTrustId}
|
|
||||||
required
|
|
||||||
autocomplete="off"
|
|
||||||
spellcheck="false"
|
|
||||||
/>
|
|
||||||
<p>Copy the Runtime-issued <code>workspace_trust_id</code> from <code>trust-workspace add/show</code> for the current enrollment. Do not use a key fingerprint or a revoked enrollment ID.</p>
|
|
||||||
<label for="runtime-public-key-input">Runtime public key</label>
|
<label for="runtime-public-key-input">Runtime public key</label>
|
||||||
<textarea
|
<textarea
|
||||||
id="runtime-public-key-input"
|
id="runtime-public-key-input"
|
||||||
@@ -548,8 +569,7 @@
|
|||||||
{busyAction === 'trust' ? 'Saving…' : actionLabel(currentAction)}
|
{busyAction === 'trust' ? 'Saving…' : actionLabel(currentAction)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="runtime-revoke-row">
|
<div class="runtime-revoke-row">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
+7
-21
@@ -10,7 +10,6 @@ import { afterEach, expect, test, vi } from "vitest";
|
|||||||
import type { ComponentProps } from "svelte";
|
import type { ComponentProps } from "svelte";
|
||||||
import RuntimeSettings from "./+page.svelte";
|
import RuntimeSettings from "./+page.svelte";
|
||||||
import RuntimeDetail from "./[runtimeId]/+page.svelte";
|
import RuntimeDetail from "./[runtimeId]/+page.svelte";
|
||||||
import { identityFixture } from "#lib/workspace/settings/identity.test-fixtures.ts";
|
|
||||||
|
|
||||||
const api = vi.hoisted(() => ({ create: vi.fn(), fingerprint: vi.fn() }));
|
const api = vi.hoisted(() => ({ create: vi.fn(), fingerprint: vi.fn() }));
|
||||||
vi.mock("$app/navigation", () => ({ invalidateAll: vi.fn(async () => {}) }));
|
vi.mock("$app/navigation", () => ({ invalidateAll: vi.fn(async () => {}) }));
|
||||||
@@ -27,14 +26,15 @@ afterEach(() => {
|
|||||||
vi.resetAllMocks();
|
vi.resetAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Runtime registration requires and preserves the Runtime-issued enrollment ID independently of the fingerprint", async () => {
|
test("Runtime registration uses public bundle without requiring copied Workspace enrollment identity", async () => {
|
||||||
api.fingerprint.mockResolvedValue("sha256:runtime-public-key");
|
api.fingerprint.mockResolvedValue("sha256:runtime-public-key");
|
||||||
api.create.mockResolvedValue({});
|
api.create.mockResolvedValue({});
|
||||||
const data = {
|
const data = {
|
||||||
workspaceId: "workspace-a",
|
workspaceId: "workspace-a",
|
||||||
workspace: { permissions: { manage_runtimes: true } },
|
workspace: { permissions: { manage_runtimes: true } },
|
||||||
runtimes: { items: [], diagnostics: [] },
|
runtimes: { items: [], diagnostics: [] },
|
||||||
signingIdentity: identityFixture("workspace-a"),
|
signingIdentity: null,
|
||||||
|
signingIdentityError: "Workspace public identity could not be loaded",
|
||||||
} as unknown as ComponentProps<typeof RuntimeSettings>["data"];
|
} as unknown as ComponentProps<typeof RuntimeSettings>["data"];
|
||||||
render(RuntimeSettings, {
|
render(RuntimeSettings, {
|
||||||
props: { data, params: { workspaceId: "workspace-a" } },
|
props: { data, params: { workspaceId: "workspace-a" } },
|
||||||
@@ -61,11 +61,7 @@ test("Runtime registration requires and preserves the Runtime-issued enrollment
|
|||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText("sha256:runtime-public-key")).toBeTruthy()
|
expect(screen.getByText("sha256:runtime-public-key")).toBeTruthy()
|
||||||
);
|
);
|
||||||
expect(submit).toHaveProperty("disabled", true);
|
expect(screen.queryByLabelText("Workspace trust ID")).toBeNull();
|
||||||
expect(api.create).not.toHaveBeenCalled();
|
|
||||||
await fireEvent.input(screen.getByLabelText("Workspace trust ID"), {
|
|
||||||
target: { value: "Runtime-issued-enrollment-A" },
|
|
||||||
});
|
|
||||||
expect(submit).toHaveProperty("disabled", false);
|
expect(submit).toHaveProperty("disabled", false);
|
||||||
await fireEvent.submit(submit.closest("form")!);
|
await fireEvent.submit(submit.closest("form")!);
|
||||||
await waitFor(() => expect(api.create).toHaveBeenCalledOnce());
|
await waitFor(() => expect(api.create).toHaveBeenCalledOnce());
|
||||||
@@ -74,14 +70,13 @@ test("Runtime registration requires and preserves the Runtime-issued enrollment
|
|||||||
identity_id: "runtime-a",
|
identity_id: "runtime-a",
|
||||||
public_key: "runtime-public-key",
|
public_key: "runtime-public-key",
|
||||||
},
|
},
|
||||||
workspace_trust_id: "Runtime-issued-enrollment-A",
|
|
||||||
display_name: null,
|
display_name: null,
|
||||||
endpoint: "https://runtime.example",
|
endpoint: "https://runtime.example",
|
||||||
expected_binding_id: null,
|
expected_binding_id: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("Runtime reactivation submits a new enrollment ID with the observed binding instead of reusing revoked trust", async () => {
|
test("Runtime reactivation uses the observed binding without copied Workspace identity", async () => {
|
||||||
api.fingerprint.mockResolvedValue("sha256:replacement-key");
|
api.fingerprint.mockResolvedValue("sha256:replacement-key");
|
||||||
api.create.mockResolvedValue({});
|
api.create.mockResolvedValue({});
|
||||||
const data = {
|
const data = {
|
||||||
@@ -100,10 +95,8 @@ test("Runtime reactivation submits a new enrollment ID with the observed binding
|
|||||||
config_managed: true,
|
config_managed: true,
|
||||||
removable: true,
|
removable: true,
|
||||||
binding: {
|
binding: {
|
||||||
state: "revoked",
|
revoked_at: "2026-10-10T00:00:00Z",
|
||||||
connection_state: "revoked",
|
|
||||||
binding_id: "observed-binding",
|
binding_id: "observed-binding",
|
||||||
workspace_trust_id: "revoked-enrollment",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -121,13 +114,7 @@ test("Runtime reactivation submits a new enrollment ID with the observed binding
|
|||||||
params: { workspaceId: "workspace-a", runtimeId: "runtime-a" },
|
params: { workspaceId: "workspace-a", runtimeId: "runtime-a" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(screen.getByLabelText("Workspace trust ID")).toHaveProperty(
|
expect(screen.queryByLabelText("Workspace trust ID")).toBeNull();
|
||||||
"value",
|
|
||||||
"",
|
|
||||||
);
|
|
||||||
await fireEvent.input(screen.getByLabelText("Workspace trust ID"), {
|
|
||||||
target: { value: "new-enrollment" },
|
|
||||||
});
|
|
||||||
await fireEvent.input(screen.getByLabelText("Runtime public key"), {
|
await fireEvent.input(screen.getByLabelText("Runtime public key"), {
|
||||||
target: { value: "yoi-ed25519-pub:v1:new-key" },
|
target: { value: "yoi-ed25519-pub:v1:new-key" },
|
||||||
});
|
});
|
||||||
@@ -145,7 +132,6 @@ test("Runtime reactivation submits a new enrollment ID with the observed binding
|
|||||||
identity_id: "runtime-a",
|
identity_id: "runtime-a",
|
||||||
public_key: "yoi-ed25519-pub:v1:new-key",
|
public_key: "yoi-ed25519-pub:v1:new-key",
|
||||||
},
|
},
|
||||||
workspace_trust_id: "new-enrollment",
|
|
||||||
display_name: "Runtime A",
|
display_name: "Runtime A",
|
||||||
endpoint: "https://runtime.example",
|
endpoint: "https://runtime.example",
|
||||||
expected_binding_id: "observed-binding",
|
expected_binding_id: "observed-binding",
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ Deno.test("Repository SSH probe offers configured remote Runtimes regardless of
|
|||||||
endpoint_configured: true,
|
endpoint_configured: true,
|
||||||
endpoint_display: "https://arcadia.example",
|
endpoint_display: "https://arcadia.example",
|
||||||
binding: {
|
binding: {
|
||||||
state: "verified",
|
binding_id: "observed-binding",
|
||||||
|
revoked_at: null,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as unknown as WorkspaceRuntimeResource;
|
} as unknown as WorkspaceRuntimeResource;
|
||||||
@@ -68,7 +69,10 @@ Deno.test("Repository SSH probe offers configured remote Runtimes regardless of
|
|||||||
runtime_id: "revoked",
|
runtime_id: "revoked",
|
||||||
management: {
|
management: {
|
||||||
...configured.management,
|
...configured.management,
|
||||||
binding: { state: "revoked" },
|
binding: {
|
||||||
|
binding_id: "revoked-binding",
|
||||||
|
revoked_at: "2026-10-10T00:00:00Z",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
} as unknown as WorkspaceRuntimeResource;
|
} as unknown as WorkspaceRuntimeResource;
|
||||||
const unbound = {
|
const unbound = {
|
||||||
|
|||||||
@@ -0,0 +1,308 @@
|
|||||||
|
import { assert, assertEquals } from "jsr:@std/assert@1.0.19";
|
||||||
|
import { chromium } from "npm:playwright@1.59.1";
|
||||||
|
|
||||||
|
// Production static build plus loopback fixtures only; no live accounts or keys.
|
||||||
|
// --before retains the superseded wire shape solely for before/after visual evidence.
|
||||||
|
Deno.test("Runtime settings keep list, editing and testing accessible after signed-ping authentication failure", async () => {
|
||||||
|
const before = Deno.args.includes("--before");
|
||||||
|
const root = new URL("../../../../", import.meta.url).pathname;
|
||||||
|
const output = new URL(
|
||||||
|
`../../.svelte-kit/runtime-corrective/${before ? "before" : "after"}/`,
|
||||||
|
import.meta.url,
|
||||||
|
).pathname;
|
||||||
|
await Deno.mkdir(output, { recursive: true });
|
||||||
|
const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||||
|
const port = (listener.addr as Deno.NetAddr).port;
|
||||||
|
listener.close();
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const server = new Deno.Command(Deno.execPath(), {
|
||||||
|
args: [
|
||||||
|
"run",
|
||||||
|
"--allow-net",
|
||||||
|
"--allow-read",
|
||||||
|
`${root}tools/web-ux/browser-tests/workspace_home_fixture_server.ts`,
|
||||||
|
String(port),
|
||||||
|
`${root}web/workspace/build`,
|
||||||
|
],
|
||||||
|
stdout: "null",
|
||||||
|
stderr: "null",
|
||||||
|
}).spawn();
|
||||||
|
try {
|
||||||
|
// Wait for an observable ready response, bounded by timeout.
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
let stopped = false;
|
||||||
|
const deadline = setTimeout(() => {
|
||||||
|
stopped = true;
|
||||||
|
reject(new Error("fixture readiness timed out"));
|
||||||
|
}, 10000);
|
||||||
|
const probe = async () => {
|
||||||
|
if (stopped) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${base}/health`);
|
||||||
|
await response.body?.cancel();
|
||||||
|
if (response.ok) {
|
||||||
|
clearTimeout(deadline);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch { /* owned server is starting */ }
|
||||||
|
setTimeout(probe, 25);
|
||||||
|
};
|
||||||
|
void probe();
|
||||||
|
});
|
||||||
|
const browser = await chromium.launch({ headless: true });
|
||||||
|
const evidence: unknown[] = [];
|
||||||
|
try {
|
||||||
|
for (const colorScheme of ["light", "dark"] as const) {
|
||||||
|
for (const width of [1440, 768, 390]) {
|
||||||
|
for (const owner of [true, false]) {
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width, height: 900 },
|
||||||
|
colorScheme,
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const page = await context.newPage();
|
||||||
|
const errors: string[] = [];
|
||||||
|
let documentStatus: number | null = null;
|
||||||
|
const navigate = async (url: string) => {
|
||||||
|
documentStatus = (await page.goto(url))?.status() ?? null;
|
||||||
|
assertEquals(documentStatus, 200);
|
||||||
|
};
|
||||||
|
page.on("response", (response) => {
|
||||||
|
if (response.status() >= 400) {
|
||||||
|
errors.push(`${response.status()} ${response.url()}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
page.on("pageerror", (error) => errors.push(String(error)));
|
||||||
|
page.on("console", (message) => {
|
||||||
|
if (message.type() === "error") errors.push(message.text());
|
||||||
|
});
|
||||||
|
page.on("requestfailed", (request) => errors.push(request.url()));
|
||||||
|
const workspace = owner ? "home-owner" : "home-member";
|
||||||
|
const binding = before
|
||||||
|
? {
|
||||||
|
binding_id: "binding-observed",
|
||||||
|
state: "configured",
|
||||||
|
connection_state: "unavailable",
|
||||||
|
workspace_key_id: "workspace-key",
|
||||||
|
workspace_trust_id: "enrollment",
|
||||||
|
}
|
||||||
|
: { binding_id: "binding-observed", revoked_at: null };
|
||||||
|
const runtime = {
|
||||||
|
runtime_id: "fixture-runtime",
|
||||||
|
label: "Development Runtime",
|
||||||
|
kind: "remote",
|
||||||
|
status: "unavailable",
|
||||||
|
source: {
|
||||||
|
kind: "remote_http",
|
||||||
|
status: "active",
|
||||||
|
identity_authority: "server_runtime_configuration",
|
||||||
|
note: "Fixture",
|
||||||
|
},
|
||||||
|
host_ids: [],
|
||||||
|
worker_creation_available: false,
|
||||||
|
os: "linux",
|
||||||
|
arch: "x86_64",
|
||||||
|
diagnostics: [{
|
||||||
|
code: "runtime_authentication_failed",
|
||||||
|
severity: "error",
|
||||||
|
message:
|
||||||
|
"The signed Runtime request was not authenticated. Update the endpoint or key and retry.",
|
||||||
|
}],
|
||||||
|
management: {
|
||||||
|
built_in: false,
|
||||||
|
config_managed: true,
|
||||||
|
removable: true,
|
||||||
|
endpoint_configured: true,
|
||||||
|
token_ref_configured: false,
|
||||||
|
binding,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
await page.route(
|
||||||
|
"**/api/w/*/runtimes",
|
||||||
|
(route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
workspace_id: workspace,
|
||||||
|
limit: 200,
|
||||||
|
source: "fixture",
|
||||||
|
items: [runtime],
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route(
|
||||||
|
"**/api/w/*/runtimes/fixture-runtime",
|
||||||
|
(route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
workspace_id: workspace,
|
||||||
|
runtime,
|
||||||
|
endpoint: "https://runtime.example.test",
|
||||||
|
trust_key: {
|
||||||
|
status: "active",
|
||||||
|
binding_id: "binding-observed",
|
||||||
|
fingerprint: "sha256:runtime-public-key",
|
||||||
|
created_at: "2026-10-01T00:00:00Z",
|
||||||
|
updated_at: "2026-10-01T00:00:00Z",
|
||||||
|
revoked_at: null,
|
||||||
|
},
|
||||||
|
recent_audit: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await page.route(
|
||||||
|
"**/api/w/*/runtimes/fixture-runtime/connection-tests",
|
||||||
|
(route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: {
|
||||||
|
workspace_id: workspace,
|
||||||
|
runtime_id: "fixture-runtime",
|
||||||
|
binding_id: "binding-observed",
|
||||||
|
...(before
|
||||||
|
? {
|
||||||
|
connection_state: "unavailable",
|
||||||
|
verification: null,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
checked_at: "2026-10-10T00:00:00Z",
|
||||||
|
status: "failed",
|
||||||
|
failure_kind: "authentication",
|
||||||
|
expected_protocol_version: 1,
|
||||||
|
actual_protocol_version: null,
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const prefix = `${colorScheme}-${width}-${
|
||||||
|
owner ? "owner" : "member"
|
||||||
|
}`;
|
||||||
|
const capture = async (name: string) => {
|
||||||
|
await page.screenshot({
|
||||||
|
path: `${output}${prefix}-${name}.png`,
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
evidence.push({
|
||||||
|
colorScheme,
|
||||||
|
width,
|
||||||
|
owner,
|
||||||
|
name,
|
||||||
|
url: page.url(),
|
||||||
|
documentStatus,
|
||||||
|
screenshot: `${prefix}-${name}.png`,
|
||||||
|
errors: [...errors],
|
||||||
|
accessibility: await page.locator("main").ariaSnapshot(),
|
||||||
|
});
|
||||||
|
assertEquals(
|
||||||
|
await page.evaluate(
|
||||||
|
"document.documentElement.scrollWidth > innerWidth",
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
prefix,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
await navigate(`${base}/w/${workspace}/settings/runtimes`);
|
||||||
|
await page.getByRole("link", {
|
||||||
|
name: "Development Runtime",
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
await page.getByRole("button", { name: "Test", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByText("Connection test: Authentication failed", {
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
await capture("list-failed");
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("button", { name: "Test", exact: true })
|
||||||
|
.isEnabled(),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
if (owner) {
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Add Runtime",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
await page.getByLabel("Runtime public bundle", { exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await capture("registration");
|
||||||
|
}
|
||||||
|
await navigate(
|
||||||
|
`${base}/w/${workspace}/settings/runtimes/fixture-runtime`,
|
||||||
|
);
|
||||||
|
await page.getByRole("heading", {
|
||||||
|
name: "Identity and binding",
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
await capture("detail");
|
||||||
|
if (!before) {
|
||||||
|
await page.getByRole("button", { name: "Test", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByText("Signed ping failed · authentication", {
|
||||||
|
exact: true,
|
||||||
|
}).waitFor();
|
||||||
|
await capture("detail-failed");
|
||||||
|
assert(
|
||||||
|
await page.getByRole("button", { name: "Test", exact: true })
|
||||||
|
.isEnabled(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Edit Runtime",
|
||||||
|
exact: true,
|
||||||
|
}).count(),
|
||||||
|
owner ? 1 : 0,
|
||||||
|
);
|
||||||
|
if (owner) {
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Edit Runtime",
|
||||||
|
exact: true,
|
||||||
|
}).click();
|
||||||
|
await page.getByLabel("Endpoint", { exact: true }).waitFor();
|
||||||
|
await capture("edit");
|
||||||
|
assert(
|
||||||
|
await page.getByRole("button", {
|
||||||
|
name: "Save Runtime settings",
|
||||||
|
exact: true,
|
||||||
|
}).isEnabled(),
|
||||||
|
);
|
||||||
|
if (!before) {
|
||||||
|
assertEquals(
|
||||||
|
await page.getByLabel("Workspace trust ID", { exact: true })
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!before) {
|
||||||
|
Object.assign(binding, { revoked_at: "2026-10-10T00:00:00Z" });
|
||||||
|
await page.goto(`${base}/w/${workspace}/settings/runtimes`);
|
||||||
|
await page.getByRole("cell", { name: "Revoked", exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assertEquals(
|
||||||
|
await page.getByRole("button", { name: "Test", exact: true })
|
||||||
|
.count(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
await capture("revoked");
|
||||||
|
}
|
||||||
|
assertEquals(errors, []);
|
||||||
|
} finally {
|
||||||
|
await context.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
await Deno.writeTextFile(
|
||||||
|
`${output}review-context.json`,
|
||||||
|
JSON.stringify(evidence, null, 2),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
server.kill("SIGTERM");
|
||||||
|
await server.status;
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -20,8 +20,6 @@ function compatibleResponse(): Record<string, unknown> {
|
|||||||
workspace_id: "workspace-a",
|
workspace_id: "workspace-a",
|
||||||
runtime_id: "runtime-a",
|
runtime_id: "runtime-a",
|
||||||
binding_id: "binding-3",
|
binding_id: "binding-3",
|
||||||
connection_state: "verified",
|
|
||||||
verification: null,
|
|
||||||
checked_at: "2026-09-01T12:00:00Z",
|
checked_at: "2026-09-01T12:00:00Z",
|
||||||
status: "compatible",
|
status: "compatible",
|
||||||
failure_kind: null,
|
failure_kind: null,
|
||||||
@@ -63,16 +61,7 @@ Deno.test("runtime connection response rejects unknown fields and incoherent com
|
|||||||
assertEquals(
|
assertEquals(
|
||||||
parseRuntimeConnectionTestResponse({
|
parseRuntimeConnectionTestResponse({
|
||||||
...compatibleResponse(),
|
...compatibleResponse(),
|
||||||
verification: {
|
verification: {},
|
||||||
verified_at: "2026-09-01T12:00:00Z",
|
|
||||||
last_checked_at: "2026-09-01T12:00:01Z",
|
|
||||||
last_outcome: "verified",
|
|
||||||
binding_id: "binding-2",
|
|
||||||
workspace_key_id: "WK-a",
|
|
||||||
workspace_public_key_fingerprint: "sha256:workspace",
|
|
||||||
workspace_trust_id: "trust-a",
|
|
||||||
runtime_public_key_fingerprint: "sha256:runtime",
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
@@ -120,3 +109,26 @@ Deno.test("runtime connection request rejects a mismatched response identity", a
|
|||||||
"Connection test response did not match the selected Runtime",
|
"Connection test response did not match the selected Runtime",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("runtime authentication failure remains an observed result without granting approval", async () => {
|
||||||
|
const failed = {
|
||||||
|
...compatibleResponse(),
|
||||||
|
status: "failed",
|
||||||
|
failure_kind: "authentication",
|
||||||
|
actual_protocol_version: null,
|
||||||
|
};
|
||||||
|
assertEquals(parseRuntimeConnectionTestResponse(failed), failed);
|
||||||
|
const fetched = await testRuntimeConnection(
|
||||||
|
"workspace-a",
|
||||||
|
"runtime-a",
|
||||||
|
(() => Promise.resolve(Response.json(failed))) as typeof fetch,
|
||||||
|
);
|
||||||
|
assertEquals(fetched, failed);
|
||||||
|
assertEquals(
|
||||||
|
parseRuntimeConnectionTestResponse({
|
||||||
|
...failed,
|
||||||
|
connection_state: "verified",
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Deno.test("Runtime registration presents the complete multi-Workspace trust sequ
|
|||||||
"yoi-runtime identity show --json",
|
"yoi-runtime identity show --json",
|
||||||
"3. Register the connection",
|
"3. Register the connection",
|
||||||
"Register Runtime",
|
"Register Runtime",
|
||||||
"Run Test to complete authenticated verification.",
|
"Run Test to observe a signed ping.",
|
||||||
]
|
]
|
||||||
) {
|
) {
|
||||||
assert(
|
assert(
|
||||||
@@ -129,16 +129,14 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
|||||||
|
|
||||||
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
|
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
|
||||||
const reveal = page.indexOf("Reveal public key");
|
const reveal = page.indexOf("Reveal public key");
|
||||||
const verifiedKey = page.indexOf("{#if verifiedTrust}");
|
|
||||||
const mutation = page.indexOf('id="runtime-public-key-input"');
|
const mutation = page.indexOf('id="runtime-public-key-input"');
|
||||||
const metadataStart = page.indexOf("async function saveRuntimeMetadata");
|
const metadataStart = page.indexOf("async function saveRuntimeMetadata");
|
||||||
const trustStart = page.indexOf("async function saveTrustKey");
|
const trustStart = page.indexOf("async function saveTrustKey");
|
||||||
const metadataMutation = page.slice(metadataStart, trustStart);
|
const metadataMutation = page.slice(metadataStart, trustStart);
|
||||||
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
|
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
|
||||||
assert(
|
assert(
|
||||||
verifiedKey >= 0 && verifiedKey < mutation &&
|
!page.includes("verifiedTrust") && !page.includes("workspace_trust_id"),
|
||||||
page.slice(verifiedKey, mutation).includes("{:else}"),
|
"cached verification and copied enrollment identity must not gate key editing",
|
||||||
"verified Runtime public key must be read-only while unverified trust keeps key input",
|
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
metadataMutation.includes("updateRemoteRuntime(") &&
|
metadataMutation.includes("updateRemoteRuntime(") &&
|
||||||
@@ -150,7 +148,7 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
|||||||
!metadataMutation.includes("publicKey") &&
|
!metadataMutation.includes("publicKey") &&
|
||||||
!metadataMutation.includes("public_bundle") &&
|
!metadataMutation.includes("public_bundle") &&
|
||||||
!metadataMutation.includes("public_key"),
|
!metadataMutation.includes("public_key"),
|
||||||
"verified Runtime metadata updates must not carry public key authority",
|
"Runtime metadata updates must not carry public key authority",
|
||||||
);
|
);
|
||||||
assert(
|
assert(
|
||||||
page.includes("Current fingerprint"),
|
page.includes("Current fingerprint"),
|
||||||
@@ -195,7 +193,6 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
|
|||||||
"saveRuntimeMetadata",
|
"saveRuntimeMetadata",
|
||||||
"Runtime settings",
|
"Runtime settings",
|
||||||
"Edit Runtime",
|
"Edit Runtime",
|
||||||
"This verified key is read-only",
|
|
||||||
"publicKey = ''",
|
"publicKey = ''",
|
||||||
"requestError = null",
|
"requestError = null",
|
||||||
"successMessage = null",
|
"successMessage = null",
|
||||||
@@ -246,3 +243,33 @@ Deno.test("Runtime detail uses flat sections instead of nested cards", async ()
|
|||||||
"Runtime detail hierarchy should use flat section separators",
|
"Runtime detail hierarchy should use flat section separators",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime labels and recovery controls do not depend on persisted authentication approval", async () => {
|
||||||
|
for (const name of ["+page.svelte", "[runtimeId]/+page.svelte"]) {
|
||||||
|
const page = await Deno.readTextFile(
|
||||||
|
new URL(
|
||||||
|
"../src/routes/w/[workspaceId]/settings/runtimes/" + name,
|
||||||
|
import.meta.url,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
for (
|
||||||
|
const field of [
|
||||||
|
"connection_state",
|
||||||
|
"verification",
|
||||||
|
"workspace_key_id",
|
||||||
|
"workspace_trust_id",
|
||||||
|
"Verification required",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
assert(!page.includes(field), "Runtime page retained " + field);
|
||||||
|
}
|
||||||
|
assert(
|
||||||
|
page.includes("revoked_at"),
|
||||||
|
"explicit revocation must remain visible",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
page.includes("testRuntimeConnection("),
|
||||||
|
"actual test must remain available",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ declare const Deno: {
|
|||||||
test(name: string, fn: () => void | Promise<void>): void;
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
import type { RepositoryApiError } from "../src/lib/generated/repository-api.ts";
|
||||||
|
import type { RuntimeManagementApiError } from "../src/lib/generated/runtime-api.ts";
|
||||||
import {
|
import {
|
||||||
createRemoteRuntime,
|
createRemoteRuntime,
|
||||||
parseRuntimeRemovalOperationResponse,
|
parseRuntimeRemovalOperationResponse,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
revokeRuntimeTrustKey,
|
revokeRuntimeTrustKey,
|
||||||
RuntimeRemovalAttempt,
|
RuntimeRemovalAttempt,
|
||||||
RuntimeTrustConflictError,
|
RuntimeTrustConflictError,
|
||||||
|
RuntimeTrustRequestError,
|
||||||
RuntimeTrustRouteFence,
|
RuntimeTrustRouteFence,
|
||||||
updateRemoteRuntime,
|
updateRemoteRuntime,
|
||||||
} from "../src/lib/workspace/api/runtime-management.ts";
|
} from "../src/lib/workspace/api/runtime-management.ts";
|
||||||
@@ -44,21 +47,8 @@ function runtime() {
|
|||||||
endpoint_configured: true,
|
endpoint_configured: true,
|
||||||
token_ref_configured: false,
|
token_ref_configured: false,
|
||||||
binding: {
|
binding: {
|
||||||
state: "verified",
|
|
||||||
connection_state: "verified",
|
|
||||||
binding_id: "binding-3",
|
binding_id: "binding-3",
|
||||||
workspace_key_id: "WK-1",
|
revoked_at: null,
|
||||||
workspace_trust_id: "trust-1",
|
|
||||||
verification: {
|
|
||||||
verified_at: "2026-09-01T13:00:00Z",
|
|
||||||
last_checked_at: "2026-09-01T13:00:00Z",
|
|
||||||
last_outcome: "verified",
|
|
||||||
binding_id: "binding-3",
|
|
||||||
workspace_key_id: "WK-1",
|
|
||||||
workspace_public_key_fingerprint: "sha256:workspace-key",
|
|
||||||
workspace_trust_id: "trust-1",
|
|
||||||
runtime_public_key_fingerprint: "SHA256:current",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
runtime_id: "arcadia",
|
runtime_id: "arcadia",
|
||||||
@@ -117,8 +107,8 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert(
|
assert(
|
||||||
list.items[0]?.management.binding?.state === "verified",
|
list.items[0]?.management.binding?.binding_id === "binding-3",
|
||||||
"binding state was not preserved",
|
"binding identity was not preserved",
|
||||||
);
|
);
|
||||||
|
|
||||||
const parsed = parseWorkspaceRuntimeDetail(detail());
|
const parsed = parseWorkspaceRuntimeDetail(detail());
|
||||||
@@ -142,9 +132,7 @@ Deno.test("Runtime list parser accepts the built-in Runtime's internal binding",
|
|||||||
const binding = embedded.management.binding as Partial<
|
const binding = embedded.management.binding as Partial<
|
||||||
typeof embedded.management.binding
|
typeof embedded.management.binding
|
||||||
>;
|
>;
|
||||||
delete binding.workspace_key_id;
|
delete binding.revoked_at;
|
||||||
delete binding.workspace_trust_id;
|
|
||||||
delete binding.verification;
|
|
||||||
|
|
||||||
const list = parseWorkspaceRuntimeList({
|
const list = parseWorkspaceRuntimeList({
|
||||||
workspace_id: "workspace-a",
|
workspace_id: "workspace-a",
|
||||||
@@ -154,23 +142,51 @@ Deno.test("Runtime list parser accepts the built-in Runtime's internal binding",
|
|||||||
diagnostics: [],
|
diagnostics: [],
|
||||||
});
|
});
|
||||||
assert(
|
assert(
|
||||||
list.items[0]?.management.binding?.connection_state === "verified",
|
list.items[0]?.management.binding?.revoked_at === null,
|
||||||
"built-in Runtime binding was not preserved",
|
"built-in Runtime binding was not preserved",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Runtime management parser rejects Workspace identity bindings without key metadata", () => {
|
Deno.test("Runtime list accepts migrated remote bindings without Workspace identity metadata", () => {
|
||||||
const payload = detail();
|
const migrated = runtime();
|
||||||
const binding = payload.runtime.management.binding as Partial<
|
const binding = migrated.management.binding as Partial<
|
||||||
typeof payload.runtime.management.binding
|
typeof migrated.management.binding
|
||||||
>;
|
>;
|
||||||
delete binding.workspace_key_id;
|
delete binding.revoked_at;
|
||||||
delete binding.workspace_trust_id;
|
const list = parseWorkspaceRuntimeList({
|
||||||
binding.state = "configured";
|
workspace_id: "workspace-a",
|
||||||
assertThrows(
|
limit: 200,
|
||||||
() => parseWorkspaceRuntimeDetail(payload),
|
items: [migrated],
|
||||||
"requires Workspace signing key identity metadata",
|
source: "fixture",
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
list.items[0]?.management.binding?.binding_id === "binding-3",
|
||||||
|
"migrated binding was rejected",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
list.items[0]?.management.binding?.revoked_at === null,
|
||||||
|
"missing revocation should remain unrevoked",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime binding rejects cached approval and copied Workspace identity fields", () => {
|
||||||
|
for (
|
||||||
|
const field of [
|
||||||
|
"state",
|
||||||
|
"connection_state",
|
||||||
|
"verification",
|
||||||
|
"workspace_key_id",
|
||||||
|
"workspace_trust_id",
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
const payload = detail();
|
||||||
|
Object.assign(payload.runtime.management.binding, { [field]: "obsolete" });
|
||||||
|
assertThrows(
|
||||||
|
() => parseWorkspaceRuntimeDetail(payload),
|
||||||
|
"not part of the wire contract",
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
|
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
|
||||||
@@ -450,82 +466,225 @@ Deno.test("Runtime public key preview matches the Server fingerprint contract",
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Runtime registration rejects empty or oversized enrollment identities before sending", async () => {
|
Deno.test("Runtime registration sends only Runtime connection data without Workspace identity copies", async () => {
|
||||||
for (const enrollment of ["", "x".repeat(257)]) {
|
const request = {
|
||||||
|
public_bundle: { identity_id: "arcadia", public_key: "test-key" },
|
||||||
|
endpoint: "https://runtime.example",
|
||||||
|
};
|
||||||
|
let body: unknown;
|
||||||
|
await createRemoteRuntime(
|
||||||
|
"workspace-a",
|
||||||
|
request,
|
||||||
|
((_input: string | URL | Request, init?: RequestInit) => {
|
||||||
|
body = JSON.parse(String(init?.body));
|
||||||
|
return Promise.resolve(Response.json(runtime()));
|
||||||
|
}) as typeof fetch,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
JSON.stringify(body) === JSON.stringify(request),
|
||||||
|
"registration copied Workspace identity or approval fields",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime trust replacement recognizes RepositoryApiError conflicts without retrying", async () => {
|
||||||
|
for (const errorKind of ["stale_binding", "fingerprint_in_use"] as const) {
|
||||||
|
for (
|
||||||
|
const diagnostics of [undefined, [], [{
|
||||||
|
code: errorKind,
|
||||||
|
severity: "error" as const,
|
||||||
|
message: "Reload Workspace trust before retrying.",
|
||||||
|
}]]
|
||||||
|
) {
|
||||||
|
const request = {
|
||||||
|
public_bundle: {
|
||||||
|
identity_id: "runtime-a",
|
||||||
|
public_key: "yoi-ed25519-pub:v1:test",
|
||||||
|
},
|
||||||
|
display_name: null,
|
||||||
|
endpoint: "https://runtime.example",
|
||||||
|
expected_binding_id: "observed-binding-id",
|
||||||
|
};
|
||||||
|
const conflict: RepositoryApiError = {
|
||||||
|
error: errorKind,
|
||||||
|
message: "Reload the authoritative Runtime trust.",
|
||||||
|
...(diagnostics === undefined ? {} : { diagnostics }),
|
||||||
|
};
|
||||||
|
let calls = 0;
|
||||||
|
const fetchImpl = ((input: RequestInfo | URL, init?: RequestInit) => {
|
||||||
|
calls++;
|
||||||
|
assert(
|
||||||
|
String(input) === "/api/w/workspace-a/runtimes",
|
||||||
|
"wrong trust route",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
init?.body === JSON.stringify(request),
|
||||||
|
"observed binding ID changed",
|
||||||
|
);
|
||||||
|
return Promise.resolve(Response.json(conflict, { status: 409 }));
|
||||||
|
}) as typeof fetch;
|
||||||
|
try {
|
||||||
|
await createRemoteRuntime("workspace-a", request, fetchImpl);
|
||||||
|
throw new Error("expected trust replacement to reject");
|
||||||
|
} catch (error) {
|
||||||
|
assert(
|
||||||
|
error instanceof RuntimeTrustConflictError,
|
||||||
|
`binding conflict was hidden: ${String(error)}`,
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
error.message === conflict.message &&
|
||||||
|
JSON.stringify(error.conflict) === JSON.stringify(conflict),
|
||||||
|
"RepositoryApiError conflict was changed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert(calls === 1, "trust replacement was retried");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime revoke preserves typed conflict binding identities and diagnostics", async () => {
|
||||||
|
const conflict: RuntimeManagementApiError = {
|
||||||
|
error: "stale_binding",
|
||||||
|
message: "Reload the authoritative Runtime trust.",
|
||||||
|
current_binding_id: "current-binding-id",
|
||||||
|
current_fingerprint: "sha256:current-key",
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await revokeRuntimeTrustKey(
|
||||||
|
"workspace-a",
|
||||||
|
"runtime-a",
|
||||||
|
{ expected_binding_id: "observed-binding-id" },
|
||||||
|
"sha256:observed-key",
|
||||||
|
"sha256:observed-key",
|
||||||
|
() => Promise.resolve(Response.json(conflict, { status: 409 })),
|
||||||
|
);
|
||||||
|
throw new Error("expected revoke to reject");
|
||||||
|
} catch (error) {
|
||||||
|
assert(
|
||||||
|
error instanceof RuntimeTrustConflictError,
|
||||||
|
"revoke conflict was hidden",
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
JSON.stringify(error.conflict) === JSON.stringify(conflict),
|
||||||
|
"typed revoke conflict identity was changed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime create surfaces other RepositoryApiError 409 messages as request errors", async () => {
|
||||||
|
for (
|
||||||
|
const diagnostics of [undefined, [], [{
|
||||||
|
code: "runtime_binding_replacement_blocked",
|
||||||
|
severity: "error" as const,
|
||||||
|
message: "An active Worker occupies this Runtime.",
|
||||||
|
}]]
|
||||||
|
) {
|
||||||
|
const payload: RepositoryApiError = {
|
||||||
|
error: "conflict",
|
||||||
|
message: "Runtime binding replacement is blocked by an active Worker.",
|
||||||
|
...(diagnostics === undefined ? {} : { diagnostics }),
|
||||||
|
};
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
try {
|
try {
|
||||||
await createRemoteRuntime("workspace-a", {
|
await createRemoteRuntime("workspace-a", {
|
||||||
public_bundle: { identity_id: "runtime-a", public_key: "test-key" },
|
public_bundle: { identity_id: "runtime-a", public_key: "test-key" },
|
||||||
workspace_trust_id: enrollment,
|
|
||||||
endpoint: "https://runtime.example",
|
endpoint: "https://runtime.example",
|
||||||
}, (() => { calls++; throw new Error("unexpected request"); }) as typeof fetch);
|
}, () => {
|
||||||
throw new Error("expected enrollment validation to reject");
|
calls++;
|
||||||
|
return Promise.resolve(Response.json(payload, { status: 409 }));
|
||||||
|
});
|
||||||
|
throw new Error("expected occupancy conflict to reject");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
assert(error instanceof Error && error.message.includes("workspace_trust_id"), "enrollment validation was bypassed");
|
|
||||||
}
|
|
||||||
assert(calls === 0, "invalid enrollment reached the Server");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Deno.test("Runtime trust replacement preserves binding conflicts without retrying", async () => {
|
|
||||||
for (const errorKind of ["stale_binding", "fingerprint_in_use"] as const) {
|
|
||||||
const request = {
|
|
||||||
public_bundle: {
|
|
||||||
identity_id: "runtime-a",
|
|
||||||
public_key: "yoi-ed25519-pub:v1:test",
|
|
||||||
},
|
|
||||||
display_name: null,
|
|
||||||
endpoint: "https://runtime.example",
|
|
||||||
workspace_trust_id: "runtime-issued-enrollment-id",
|
|
||||||
expected_binding_id: "observed-binding-id",
|
|
||||||
};
|
|
||||||
const conflict = {
|
|
||||||
error: errorKind,
|
|
||||||
message: "Reload the authoritative Runtime trust.",
|
|
||||||
current_binding_id: "current-binding-id",
|
|
||||||
current_fingerprint: "sha256:current-key",
|
|
||||||
};
|
|
||||||
let calls = 0;
|
|
||||||
const fetchImpl = ((input: RequestInfo | URL, init?: RequestInit) => {
|
|
||||||
calls++;
|
|
||||||
assert(String(input) === "/api/w/workspace-a/runtimes", "wrong trust route");
|
|
||||||
assert(init?.body === JSON.stringify(request), "observed binding ID changed");
|
|
||||||
return Promise.resolve(Response.json(conflict, { status: 409 }));
|
|
||||||
}) as typeof fetch;
|
|
||||||
try {
|
|
||||||
await createRemoteRuntime("workspace-a", request, fetchImpl);
|
|
||||||
throw new Error("expected trust replacement to reject");
|
|
||||||
} catch (error) {
|
|
||||||
assert(error instanceof RuntimeTrustConflictError, "binding conflict was hidden");
|
|
||||||
assert(
|
assert(
|
||||||
JSON.stringify(error.conflict) === JSON.stringify(conflict),
|
error instanceof RuntimeTrustRequestError,
|
||||||
"authoritative conflict identity was changed",
|
"occupancy error was misclassified",
|
||||||
);
|
);
|
||||||
|
assert(error.message === payload.message, "occupancy message was hidden");
|
||||||
}
|
}
|
||||||
assert(calls === 1, "trust replacement was retried");
|
assert(calls === 1, "occupancy conflict was retried");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Deno.test("Runtime trust replacement rejects unknown fields and missing binding identity in conflict responses", async () => {
|
Deno.test("Runtime create bounds legitimate RepositoryApiError 409 messages", async () => {
|
||||||
const fetchImpl = (() => Promise.resolve(Response.json({
|
const message = "Runtime is occupied. ".repeat(30);
|
||||||
error: "stale_binding",
|
|
||||||
message: "Stale trust",
|
|
||||||
unexpected_field: true,
|
|
||||||
}, { status: 409 }))) as typeof fetch;
|
|
||||||
try {
|
try {
|
||||||
await createRemoteRuntime("workspace-a", {
|
await createRemoteRuntime("workspace-a", {
|
||||||
public_bundle: { identity_id: "runtime-a", public_key: "test-key" },
|
public_bundle: { identity_id: "runtime-a", public_key: "test-key" },
|
||||||
endpoint: "https://runtime.example",
|
endpoint: "https://runtime.example",
|
||||||
workspace_trust_id: "runtime-issued-enrollment-id",
|
}, () =>
|
||||||
expected_binding_id: "observed-binding-id",
|
Promise.resolve(Response.json(
|
||||||
}, fetchImpl);
|
{
|
||||||
throw new Error("expected malformed conflict to reject");
|
error: "conflict",
|
||||||
|
message,
|
||||||
|
} satisfies RepositoryApiError,
|
||||||
|
{ status: 409 },
|
||||||
|
)));
|
||||||
|
throw new Error("expected occupancy conflict to reject");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
assert(!(error instanceof RuntimeTrustConflictError), "legacy conflict was accepted");
|
|
||||||
assert(
|
assert(
|
||||||
error instanceof Error && error.message === "Runtime trust conflict response was invalid",
|
error instanceof RuntimeTrustRequestError,
|
||||||
"malformed conflict did not fail closed",
|
"occupancy error was misclassified",
|
||||||
);
|
);
|
||||||
|
assert(
|
||||||
|
error.message === message.slice(0, 256),
|
||||||
|
"bounded backend message was lost",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Runtime create rejects malformed RepositoryApiError conflicts without exposing their message", async () => {
|
||||||
|
const validDiagnostic = {
|
||||||
|
code: "conflict",
|
||||||
|
severity: "error",
|
||||||
|
message: "detail",
|
||||||
|
};
|
||||||
|
for (const errorKind of ["stale_binding", "fingerprint_in_use", "conflict"]) {
|
||||||
|
const valid = {
|
||||||
|
error: errorKind,
|
||||||
|
message: "Do not expose malformed payload text",
|
||||||
|
};
|
||||||
|
for (
|
||||||
|
const payload of [
|
||||||
|
{ ...valid, unexpected_field: true },
|
||||||
|
{ ...valid, current_binding_id: "not-in-creation-contract" },
|
||||||
|
{ ...valid, current_fingerprint: "not-in-creation-contract" },
|
||||||
|
{ ...valid, error: 409 },
|
||||||
|
{ ...valid, error: "" },
|
||||||
|
{ ...valid, error: "x".repeat(257) },
|
||||||
|
{ error: errorKind },
|
||||||
|
{ ...valid, message: null },
|
||||||
|
{ ...valid, message: "" },
|
||||||
|
{ ...valid, message: "é".repeat(513) },
|
||||||
|
{ ...valid, diagnostics: null },
|
||||||
|
{ ...valid, diagnostics: {} },
|
||||||
|
{ ...valid, diagnostics: Array(65).fill(validDiagnostic) },
|
||||||
|
{
|
||||||
|
...valid,
|
||||||
|
diagnostics: [{ ...validDiagnostic, severity: "unknown" }],
|
||||||
|
},
|
||||||
|
{ ...valid, diagnostics: [{ ...validDiagnostic, code: 1 }] },
|
||||||
|
{ ...valid, diagnostics: [{ ...validDiagnostic, extra: true }] },
|
||||||
|
{
|
||||||
|
...valid,
|
||||||
|
diagnostics: [{ ...validDiagnostic, message: "x".repeat(2049) }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await createRemoteRuntime("workspace-a", {
|
||||||
|
public_bundle: { identity_id: "runtime-a", public_key: "test-key" },
|
||||||
|
endpoint: "https://runtime.example",
|
||||||
|
expected_binding_id: "observed-binding-id",
|
||||||
|
}, () => Promise.resolve(Response.json(payload, { status: 409 })));
|
||||||
|
throw new Error("expected malformed conflict to reject");
|
||||||
|
} catch (error) {
|
||||||
|
assert(
|
||||||
|
error instanceof RuntimeTrustRequestError &&
|
||||||
|
error.message === "Runtime trust conflict response was invalid",
|
||||||
|
`malformed conflict did not fail closed: ${JSON.stringify(payload)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -551,7 +710,6 @@ Deno.test("Runtime create surfaces bounded Settings error details", async () =>
|
|||||||
},
|
},
|
||||||
display_name: null,
|
display_name: null,
|
||||||
endpoint: "https://runtime.example",
|
endpoint: "https://runtime.example",
|
||||||
workspace_trust_id: "runtime-issued-enrollment-id",
|
|
||||||
expected_binding_id: null,
|
expected_binding_id: null,
|
||||||
},
|
},
|
||||||
fetchImpl,
|
fetchImpl,
|
||||||
|
|||||||
Reference in New Issue
Block a user