chore: merge develop into hare/develop

This commit is contained in:
2026-09-08 13:04:10 +09:00
38 changed files with 9700 additions and 2625 deletions
Generated
+24
View File
@@ -5086,8 +5086,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [ dependencies = [
"futures-util", "futures-util",
"log", "log",
"rustls",
"rustls-pki-types",
"tokio", "tokio",
"tokio-rustls",
"tungstenite 0.29.0", "tungstenite 0.29.0",
"webpki-roots 0.26.11",
] ]
[[package]] [[package]]
@@ -5395,6 +5399,8 @@ dependencies = [
"httparse", "httparse",
"log", "log",
"rand 0.9.4", "rand 0.9.4",
"rustls",
"rustls-pki-types",
"sha1", "sha1",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -6133,6 +6139,24 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.12" version = "0.1.12"
+4 -16
View File
@@ -12,10 +12,10 @@ use workspace_api::{
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest, ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse, RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceRuntimeResource,
}; };
use crate::{BackendApiClient, BackendWorkspaceClientError}; use crate::{BackendApiClient, BackendWorkspaceClientError};
@@ -266,18 +266,6 @@ impl BackendWorkspaceProductClient {
)) ))
} }
pub fn put_runtime_trust_key(
&self,
runtime_id: &str,
request: &PutRuntimeTrustKeyRequest,
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
self.send_json(
Method::PUT,
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
Some(request),
)
}
pub fn revoke_runtime_trust_key( pub fn revoke_runtime_trust_key(
&self, &self,
runtime_id: &str, runtime_id: &str,
+1
View File
@@ -14,6 +14,7 @@ fs-operation.workspace = true
manifest.workspace = true manifest.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true } reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true sha2.workspace = true
tempfile.workspace = true tempfile.workspace = true
thiserror.workspace = true thiserror.workspace = true
+26 -7
View File
@@ -293,7 +293,12 @@ mod client {
/// implementations can mint short-lived capability tokens without making a /// implementations can mint short-lived capability tokens without making a
/// Worker-bound session expire with the token used to open it. /// Worker-bound session expire with the token used to open it.
pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync { pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync {
fn bearer_token(&self) -> Result<String, WorkdirError>; fn bearer_token(
&self,
method: &str,
path_and_query: &str,
body: &[u8],
) -> Result<String, WorkdirError>;
} }
struct FixedBearerToken(Arc<str>); struct FixedBearerToken(Arc<str>);
@@ -305,7 +310,12 @@ mod client {
} }
impl WorkdirHttpAuthorization for FixedBearerToken { impl WorkdirHttpAuthorization for FixedBearerToken {
fn bearer_token(&self) -> Result<String, WorkdirError> { fn bearer_token(
&self,
_method: &str,
_path_and_query: &str,
_body: &[u8],
) -> Result<String, WorkdirError> {
Ok(self.0.to_string()) Ok(self.0.to_string())
} }
} }
@@ -354,10 +364,14 @@ mod client {
&base_url, &base_url,
&["v1", "working-directories", workdir_id.as_str(), "sessions"], &["v1", "working-directories", workdir_id.as_str(), "sessions"],
)?; )?;
let body = serde_json::to_vec(&request)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
let token = authorization.bearer_token("POST", url.path(), &body)?;
let response = client let response = client
.post(url) .post(url)
.bearer_auth(authorization.bearer_token()?) .bearer_auth(token)
.json(&request) .header("content-type", "application/json")
.body(body)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
@@ -401,11 +415,15 @@ mod client {
], ],
)?; )?;
let operation = WorkdirSessionOperationRequest { operation }; let operation = WorkdirSessionOperationRequest { operation };
let body = serde_json::to_vec(&operation)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
let token = self.authorization.bearer_token("POST", url.path(), &body)?;
let response = self let response = self
.client .client
.post(url) .post(url)
.bearer_auth(self.authorization.bearer_token()?) .bearer_auth(token)
.json(&operation) .header("content-type", "application/json")
.body(body)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
@@ -543,10 +561,11 @@ mod client {
&self.base_url, &self.base_url,
&["v1", "workdir-sessions", self.session_id.as_str()], &["v1", "workdir-sessions", self.session_id.as_str()],
)?; )?;
let token = self.authorization.bearer_token("DELETE", url.path(), &[])?;
let response = self let response = self
.client .client
.delete(url) .delete(url)
.bearer_auth(self.authorization.bearer_token()?) .bearer_auth(token)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
+87 -220
View File
@@ -2,6 +2,7 @@ use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD; use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::{SecureRandom, SystemRandom}; use ring::rand::{SecureRandom, SystemRandom};
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey}; use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::fmt; use std::fmt;
@@ -9,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:"; const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:"; const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
const TOKEN_PREFIX: &str = "yoi-cap-v1";
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof"; pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1"; const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1."; const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
@@ -68,6 +67,74 @@ pub enum RuntimeAuthError {
WrongMutationTarget, WrongMutationTarget,
} }
pub(crate) struct SignedJsonToken<T> {
pub payload: String,
pub signature: Vec<u8>,
pub claims: T,
}
pub(crate) fn sign_json_token<T: Serialize>(
token_prefix: &str,
signing_input_prefix: &str,
signing_key: &Ed25519KeyPair,
claims: &T,
) -> Result<String, RuntimeAuthError> {
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?);
let signing_input = format!("{signing_input_prefix}{payload}");
let signature = signing_key.sign(signing_input.as_bytes());
Ok(format!(
"{token_prefix}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
pub(crate) fn decode_signed_json_token<T: DeserializeOwned>(
token: &str,
expected_prefix: &str,
) -> Result<SignedJsonToken<T>, RuntimeAuthError> {
let (prefix, payload, signature) = split_three_part_token(token)?;
if prefix != expected_prefix {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let signature = URL_SAFE_NO_PAD.decode(signature)?;
let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?;
Ok(SignedJsonToken {
payload: payload.to_string(),
signature,
claims,
})
}
pub(crate) fn verify_signed_json_token(
signing_input_prefix: &str,
payload: &str,
signature: &[u8],
public_key: &str,
) -> Result<(), RuntimeAuthError> {
let public_key = decode_public_key(public_key)?;
let signing_input = format!("{signing_input_prefix}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)
}
fn split_three_part_token(token: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = token.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
pub(crate) fn is_request_body_digest(value: &str) -> bool {
URL_SAFE_NO_PAD
.decode(value)
.is_ok_and(|decoded| decoded.len() == 32 && URL_SAFE_NO_PAD.encode(decoded) == value)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeIdentityMaterial { pub struct RuntimeIdentityMaterial {
pub identity_id: String, pub identity_id: String,
@@ -95,21 +162,6 @@ impl RuntimeIdentityMaterial {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedServerKey {
pub server_id: String,
pub public_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpAuthConfig {
pub runtime_id: String,
#[serde(default)]
pub trusted_servers: Vec<TrustedServerKey>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthContext { pub struct RuntimeAuthContext {
pub server_id: String, pub server_id: String,
@@ -119,122 +171,6 @@ pub struct RuntimeAuthContext {
pub expires_at: u64, pub expires_at: u64,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityClaims {
pub iss: String,
pub aud: String,
pub workspace_id: String,
pub permissions: Vec<String>,
pub exp: u64,
pub jti: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapabilityTokenSigner {
server_id: String,
private_key: String,
}
impl CapabilityTokenSigner {
pub fn new(server_id: impl Into<String>, private_key: impl Into<String>) -> Self {
Self {
server_id: server_id.into(),
private_key: private_key.into(),
}
}
pub fn server_id(&self) -> &str {
&self.server_id
}
pub fn sign(&self, claims: &CapabilityClaims) -> Result<String, RuntimeAuthError> {
if claims.iss != self.server_id {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
}
let private = decode_private_key(&self.private_key)?;
let pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let payload = serde_json::to_vec(claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
let signature = pair.sign(signing_input.as_bytes());
Ok(format!(
"{TOKEN_PREFIX}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
}
pub fn capability_claims(
server_id: impl Into<String>,
runtime_id: impl Into<String>,
workspace_id: impl Into<String>,
permissions: Vec<String>,
ttl_seconds: u64,
) -> Result<CapabilityClaims, RuntimeAuthError> {
let exp = unix_now_seconds().saturating_add(ttl_seconds);
Ok(CapabilityClaims {
iss: server_id.into(),
aud: runtime_id.into(),
workspace_id: workspace_id.into(),
permissions,
exp,
jti: new_token_id()?,
})
}
pub fn verify_capability_token(
config: &RuntimeHttpAuthConfig,
token: &str,
required_permission: Option<&str>,
now_seconds: u64,
) -> Result<RuntimeAuthContext, RuntimeAuthError> {
let (payload, signature) = split_token(token)?;
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
let claims: CapabilityClaims = serde_json::from_slice(&claims_json)?;
let Some(server) = config
.trusted_servers
.iter()
.find(|server| server.server_id == claims.iss)
else {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
};
let public_key = decode_public_key(&server.public_key)?;
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
if claims.aud != config.runtime_id {
return Err(RuntimeAuthError::WrongAudience {
expected: config.runtime_id.clone(),
actual: claims.aud,
});
}
if claims.exp < now_seconds {
return Err(RuntimeAuthError::Expired);
}
if claims.workspace_id.trim().is_empty() {
return Err(RuntimeAuthError::MissingWorkspaceScope);
}
if let Some(required) = required_permission {
if !claims
.permissions
.iter()
.any(|permission| permission == required)
{
return Err(RuntimeAuthError::MissingPermission(required.to_string()));
}
}
Ok(RuntimeAuthContext {
server_id: claims.iss,
workspace_id: claims.workspace_id,
permissions: claims.permissions,
token_id: claims.jti,
expires_at: claims.exp,
})
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeRequestSourceClaims { pub struct RuntimeRequestSourceClaims {
pub iss: String, pub iss: String,
@@ -323,28 +259,22 @@ impl RuntimeRequestSourceSigner {
exp: now_unix.saturating_add(ttl_seconds), exp: now_unix.saturating_add(ttl_seconds),
jti: new_token_id()?, jti: new_token_id()?,
}; };
let payload = serde_json::to_vec(&claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let private = decode_private_key(&self.private_key)?; let private = decode_private_key(&self.private_key)?;
let key_pair = Ed25519KeyPair::from_pkcs8(&private) let key_pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?; .map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref()); sign_json_token(
Ok(format!( RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
"{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}" RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
)) &key_pair,
&claims,
)
} }
} }
pub fn decode_runtime_request_source_claims( pub fn decode_runtime_request_source_claims(
proof: &str, proof: &str,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> { ) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?; Ok(decode_signed_json_token(proof, RUNTIME_REQUEST_SOURCE_PROOF_PREFIX)?.claims)
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let payload = URL_SAFE_NO_PAD.decode(payload)?;
serde_json::from_slice(&payload).map_err(RuntimeAuthError::from)
} }
pub fn verify_runtime_request_source( pub fn verify_runtime_request_source(
@@ -352,17 +282,17 @@ pub fn verify_runtime_request_source(
public_key: &str, public_key: &str,
expected: &RuntimeRequestSourceExpectation<'_>, expected: &RuntimeRequestSourceExpectation<'_>,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> { ) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?; let signed = decode_signed_json_token::<RuntimeRequestSourceClaims>(
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX { proof,
return Err(RuntimeAuthError::InvalidTokenFormat); RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
} )?;
let signature = URL_SAFE_NO_PAD.decode(signature)?; verify_signed_json_token(
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}"); RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
let public_key = decode_public_key(public_key)?; &signed.payload,
UnparsedPublicKey::new(&ED25519, public_key) &signed.signature,
.verify(signing_input.as_bytes(), &signature) public_key,
.map_err(|_| RuntimeAuthError::InvalidSignature)?; )?;
let claims = decode_runtime_request_source_claims(proof)?; let claims = signed.claims;
if claims.iss != expected.identity_id if claims.iss != expected.identity_id
|| claims.aud != expected.audience || claims.aud != expected.audience
|| claims.workspace_id != expected.workspace_id || claims.workspace_id != expected.workspace_id
@@ -380,17 +310,6 @@ pub fn verify_runtime_request_source(
Ok(claims) Ok(claims)
} }
fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = proof.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerMutationSourceClaims { pub struct WorkerMutationSourceClaims {
pub iss: String, pub iss: String,
@@ -586,16 +505,6 @@ fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec<u8>), Ru
} }
} }
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
let mut parts = token.split('.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(prefix), Some(payload), Some(signature), None) if prefix == TOKEN_PREFIX => {
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
}
_ => Err(RuntimeAuthError::InvalidTokenFormat),
}
}
pub fn encode_public_key(bytes: &[u8]) -> String { pub fn encode_public_key(bytes: &[u8]) -> String {
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes)) format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
} }
@@ -851,46 +760,4 @@ mod tests {
Err(RuntimeAuthError::Expired) Err(RuntimeAuthError::Expired)
)); ));
} }
#[test]
fn capability_token_verifies_signature_audience_expiry_and_permission() {
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
let signer = CapabilityTokenSigner::new(&server.identity_id, &server.private_key);
let claims = CapabilityClaims {
iss: "server-main".to_string(),
aud: "runtime-main".to_string(),
workspace_id: "workspace-a".to_string(),
permissions: vec!["workers:list".to_string()],
exp: 100,
jti: "token-1".to_string(),
};
let token = signer.sign(&claims).unwrap();
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-main".to_string(),
trusted_servers: vec![TrustedServerKey {
server_id: "server-main".to_string(),
public_key: server.public_key.clone(),
display_name: None,
}],
};
let context = verify_capability_token(&auth, &token, Some("workers:list"), 99).unwrap();
assert_eq!(context.workspace_id, "workspace-a");
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:create"), 99),
Err(RuntimeAuthError::MissingPermission(permission)) if permission == "workers:create"
));
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:list"), 101),
Err(RuntimeAuthError::Expired)
));
let wrong_audience = RuntimeHttpAuthConfig {
runtime_id: "other-runtime".to_string(),
trusted_servers: auth.trusted_servers.clone(),
};
assert!(matches!(
verify_capability_token(&wrong_audience, &token, Some("workers:list"), 99),
Err(RuntimeAuthError::WrongAudience { .. })
));
}
} }
File diff suppressed because it is too large Load Diff
+1
View File
@@ -28,6 +28,7 @@ mod runtime;
pub mod worker_backend; pub mod worker_backend;
pub mod worker_source; pub mod worker_source;
pub mod working_directory; pub mod working_directory;
pub mod workspace_issuer;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+196 -21
View File
@@ -607,6 +607,64 @@ pub struct WorkspaceMetadataMutationResponse {
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
/// Lifecycle state for a Workspace-scoped Ed25519 signing identity.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceSigningIdentityState {
PendingProvisioning,
Active,
}
/// Public metadata for a Workspace signing identity. Private material and its
/// storage reference are deliberately not part of this wire authority.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceSigningIdentityPublic {
pub workspace_id: String,
pub key_id: String,
pub algorithm: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub public_key_fingerprint: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
pub state: WorkspaceSigningIdentityState,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub provisioned_at: Option<String>,
}
/// Copyable public trust bundle consumed by future Runtime enrollment work.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspacePublicIdentityBundle {
pub workspace_id: String,
pub backend_url: String,
pub key_id: String,
pub algorithm: String,
pub public_key: String,
pub public_key_fingerprint: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceSigningIdentityResponse {
pub identity: WorkspaceSigningIdentityPublic,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub public_bundle: Option<WorkspacePublicIdentityBundle>,
}
pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128;
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256; pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
@@ -1522,6 +1580,71 @@ pub struct RuntimeSummary {
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceRuntimeBindingState {
Configured,
Verified,
Revoked,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[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)]
#[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)]
#[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,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub binding_revision: u64,
pub workspace_key_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub workspace_identity_revision: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub workspace_trust_generation: u64,
pub runtime_public_key_fingerprint: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub runtime_identity_revision: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRuntimeBindingSummary {
pub state: WorkspaceRuntimeBindingState,
pub connection_state: RuntimeConnectionDisplayState,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub workspace_key_generation: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification: Option<RuntimeVerificationEvidenceSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -1531,6 +1654,8 @@ pub struct RuntimeManagementSummary {
pub removable: bool, pub removable: bool,
pub endpoint_configured: bool, pub endpoint_configured: bool,
pub token_ref_configured: bool, pub token_ref_configured: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binding: Option<WorkspaceRuntimeBindingSummary>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1613,16 +1738,6 @@ pub struct RuntimeTrustKeyRevealResponse {
pub public_key: String, pub public_key: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PutRuntimeTrustKeyRequest {
pub public_key: String,
#[serde(default)]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -1653,12 +1768,24 @@ pub struct RuntimeTrustConflictResponse {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimePublicIdentityBundle {
pub identity_id: String,
pub public_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct CreateRemoteRuntimeRequest { pub struct CreateRemoteRuntimeRequest {
pub runtime_id: String, pub public_bundle: RuntimePublicIdentityBundle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>, pub display_name: Option<String>,
pub endpoint: String, pub endpoint: String,
pub token_ref: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_revision: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1690,6 +1817,10 @@ pub enum RuntimeConnectionTestFailureKind {
pub struct RuntimeConnectionTestResponse { pub struct RuntimeConnectionTestResponse {
pub workspace_id: String, pub workspace_id: String,
pub runtime_id: String, pub runtime_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub binding_revision: u64,
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>,
@@ -2848,6 +2979,10 @@ pub fn catalog_typescript() -> String {
WorkspaceMetadataSettingsResponse::decl(&config), WorkspaceMetadataSettingsResponse::decl(&config),
UpdateWorkspaceMetadataRequest::decl(&config), UpdateWorkspaceMetadataRequest::decl(&config),
WorkspaceMetadataMutationResponse::decl(&config), WorkspaceMetadataMutationResponse::decl(&config),
WorkspaceSigningIdentityState::decl(&config),
WorkspaceSigningIdentityPublic::decl(&config),
WorkspacePublicIdentityBundle::decl(&config),
WorkspaceSigningIdentityResponse::decl(&config),
ProfileSettingsResponse::decl(&config), ProfileSettingsResponse::decl(&config),
WorkspaceProfileSummary::decl(&config), WorkspaceProfileSummary::decl(&config),
WorkspaceProfileSourceSummary::decl(&config), WorkspaceProfileSourceSummary::decl(&config),
@@ -2868,6 +3003,11 @@ pub fn catalog_typescript() -> String {
RuntimeIdentityAuthority::decl(&config), RuntimeIdentityAuthority::decl(&config),
RuntimeSourceSummary::decl(&config), RuntimeSourceSummary::decl(&config),
RuntimeSummary::decl(&config), RuntimeSummary::decl(&config),
WorkspaceRuntimeBindingState::decl(&config),
RuntimeConnectionDisplayState::decl(&config),
RuntimeVerificationOutcome::decl(&config),
RuntimeVerificationEvidenceSummary::decl(&config),
WorkspaceRuntimeBindingSummary::decl(&config),
RuntimeManagementSummary::decl(&config), RuntimeManagementSummary::decl(&config),
WorkspaceRuntimeResource::decl(&config), WorkspaceRuntimeResource::decl(&config),
RuntimeTrustKeyStatus::decl(&config), RuntimeTrustKeyStatus::decl(&config),
@@ -2876,10 +3016,11 @@ pub fn catalog_typescript() -> String {
RuntimeTrustAuditEntry::decl(&config), RuntimeTrustAuditEntry::decl(&config),
WorkspaceRuntimeDetail::decl(&config), WorkspaceRuntimeDetail::decl(&config),
RuntimeTrustKeyRevealResponse::decl(&config), RuntimeTrustKeyRevealResponse::decl(&config),
PutRuntimeTrustKeyRequest::decl(&config),
RevokeRuntimeTrustKeyRequest::decl(&config), RevokeRuntimeTrustKeyRequest::decl(&config),
RuntimeTrustConflictKind::decl(&config), RuntimeTrustConflictKind::decl(&config),
RuntimeTrustConflictResponse::decl(&config), RuntimeTrustConflictResponse::decl(&config),
RuntimePublicIdentityBundle::decl(&config),
CreateRemoteRuntimeRequest::decl(&config),
RuntimeConnectionTestStatus::decl(&config), RuntimeConnectionTestStatus::decl(&config),
RuntimeConnectionTestFailureKind::decl(&config), RuntimeConnectionTestFailureKind::decl(&config),
RuntimeConnectionTestResponse::decl(&config), RuntimeConnectionTestResponse::decl(&config),
@@ -3707,14 +3848,6 @@ mod tests {
})) }))
.is_err() .is_err()
); );
assert!(
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
"public_key": "key",
"expected_revision": 1,
"replace": true
}))
.is_err()
);
assert!( assert!(
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({ serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
"expected_revision": 1, "expected_revision": 1,
@@ -3729,6 +3862,9 @@ mod tests {
let compatible = serde_json::json!({ let compatible = serde_json::json!({
"workspace_id": "workspace-test", "workspace_id": "workspace-test",
"runtime_id": "runtime-test", "runtime_id": "runtime-test",
"binding_revision": 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,
@@ -3877,6 +4013,45 @@ mod tests {
); );
} }
#[test]
fn workspace_signing_identity_wire_contract_omits_private_and_pending_fields() {
let response = WorkspaceSigningIdentityResponse {
identity: WorkspaceSigningIdentityPublic {
workspace_id: "workspace-test".to_string(),
key_id: "WK-test".to_string(),
algorithm: "ed25519".to_string(),
public_key: None,
public_key_fingerprint: None,
revision: 1,
state: WorkspaceSigningIdentityState::PendingProvisioning,
created_at: "2026-01-01T00:00:00Z".to_string(),
provisioned_at: None,
},
public_bundle: None,
};
let encoded = serde_json::to_value(&response).unwrap();
assert_eq!(
encoded,
serde_json::json!({
"identity": {
"workspace_id": "workspace-test",
"key_id": "WK-test",
"algorithm": "ed25519",
"revision": 1,
"state": "pending_provisioning",
"created_at": "2026-01-01T00:00:00Z"
}
})
);
assert!(
serde_json::from_value::<WorkspaceSigningIdentityResponse>(serde_json::json!({
"identity": encoded["identity"].clone(),
"private_material_ref": "must-not-cross-the-wire"
}))
.is_err()
);
}
fn companion_worker() -> WorkspaceWorkerDiscoveryItem { fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
WorkspaceWorkerDiscoveryItem { WorkspaceWorkerDiscoveryItem {
subject: WorkspaceWorkerSubject::RuntimeWorker { subject: WorkspaceWorkerSubject::RuntimeWorker {
+1 -1
View File
@@ -38,7 +38,7 @@ memory.workspace = true
merge-request.workspace = true merge-request.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] } tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
tower.workspace = true tower.workspace = true
tokio-tungstenite.workspace = true tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
worker.workspace = true worker.workspace = true
workspace-api.workspace = true workspace-api.workspace = true
workdir = { workspace = true, features = ["http-client"] } workdir = { workspace = true, features = ["http-client"] }
File diff suppressed because it is too large Load Diff
+82 -1
View File
@@ -441,13 +441,49 @@ 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_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0), binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
workspace_key_id TEXT,
workspace_key_generation INTEGER CHECK (workspace_key_generation > 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_key_generation IS NULL)
OR
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation 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 (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
binding_revision INTEGER NOT NULL CHECK(binding_revision > 0),
workspace_key_id TEXT NOT NULL,
workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0),
workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0),
runtime_public_key_fingerprint TEXT NOT NULL,
runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0),
challenge_id TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
last_outcome TEXT NOT NULL,
verified_at TEXT,
checked_at TEXT NOT NULL,
PRIMARY KEY(workspace_id, runtime_id),
FOREIGN KEY(workspace_id, runtime_id)
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE,
CHECK((state = 'verified' AND verified_at IS NOT NULL)
OR (state != 'verified' AND verified_at IS NULL))
);
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,
@@ -795,6 +831,51 @@ CREATE TABLE workspace_create_operations (
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
); );
CREATE TABLE workspace_signing_identities (
workspace_id TEXT PRIMARY KEY,
key_id TEXT NOT NULL UNIQUE,
algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'),
public_key TEXT,
public_key_fingerprint TEXT,
private_material_ref TEXT NOT NULL UNIQUE,
revision INTEGER NOT NULL CHECK (revision >= 1),
state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')),
created_at TEXT NOT NULL,
provisioned_at TEXT,
updated_at TEXT NOT NULL,
CHECK (
(state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL)
OR
(state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL)
),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE workspace_signing_identity_provisioning_operations (
operation_key TEXT PRIMARY KEY,
request_fingerprint TEXT NOT NULL,
operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')),
workspace_id TEXT NOT NULL UNIQUE,
key_id TEXT NOT NULL UNIQUE,
private_material_ref TEXT NOT NULL UNIQUE,
revision INTEGER NOT NULL CHECK (revision >= 1),
actor_account_id TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE workspace_signing_identity_audit (
event_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
key_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('provisioned')),
revision INTEGER NOT NULL CHECK (revision >= 1),
public_key_fingerprint TEXT NOT NULL,
actor_account_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE INDEX workspace_signing_identity_audit_workspace_idx
ON workspace_signing_identity_audit(workspace_id, created_at DESC);
CREATE TABLE workspace_memory_documents ( CREATE TABLE workspace_memory_documents (
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE, workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
body_md TEXT NOT NULL, body_md TEXT NOT NULL,
+3
View File
@@ -34,6 +34,7 @@ mod workdir_removal;
pub mod worker_source; pub mod worker_source;
pub mod workspace_catalog; pub mod workspace_catalog;
mod workspace_deletion; mod workspace_deletion;
pub mod workspace_signing_identity;
mod workspace_subscription; mod workspace_subscription;
pub use authority::{ pub use authority::{
@@ -138,6 +139,8 @@ pub enum Error {
WorkerSourceIdentity(String), WorkerSourceIdentity(String),
#[error("workspace identity error: {0}")] #[error("workspace identity error: {0}")]
WorkspaceIdentity(String), WorkspaceIdentity(String),
#[error("Workspace signing identity error ({code}): {message}")]
WorkspaceSigningIdentity { code: String, message: String },
#[error("store error: {0}")] #[error("store error: {0}")]
Store(String), Store(String),
} }
+68 -445
View File
@@ -1,15 +1,15 @@
use std::collections::VecDeque;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use std::process::ExitCode; use std::process::ExitCode;
use std::sync::Arc; use std::sync::Arc;
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key}; use yoi_workspace_server::hosts::{EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig}; use yoi_workspace_server::store::{
use yoi_workspace_server::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding}; SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
WorkspaceRuntimeBindingState,
};
use yoi_workspace_server::{ use yoi_workspace_server::{
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
@@ -18,8 +18,6 @@ use yoi_workspace_server::{
#[derive(Debug)] #[derive(Debug)]
enum Command { enum Command {
Serve(ServeOptions), Serve(ServeOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
Migrate(MigrateOptions), Migrate(MigrateOptions),
Skills(SkillsCommand), Skills(SkillsCommand),
Help, Help,
@@ -76,8 +74,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>(); let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? { match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await, Command::Serve(options) => run_serve(options).await,
Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::Migrate(options) => run_migrate(options), Command::Migrate(options) => run_migrate(options),
Command::Skills(command) => run_skills(command), Command::Skills(command) => run_skills(command),
Command::Help => Ok(()), Command::Help => Ok(()),
@@ -91,8 +87,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
}; };
match command.as_str() { match command.as_str() {
"identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_options(rest).map(Command::Migrate), "migrate" => parse_migrate_options(rest).map(Command::Migrate),
"skills" => parse_skills_command(rest), "skills" => parse_skills_command(rest),
"serve" => { "serve" => {
@@ -107,371 +101,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help) Ok(Command::Help)
} }
other => Err(CliError(format!( other => Err(CliError(format!(
"unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`" "unknown command `{other}`; expected `migrate`, `skills`, or `serve`"
))), ))),
} }
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct ServerIdentityFile {
identity: RuntimeIdentityMaterial,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct PublicIdentityView {
identity_id: String,
public_key: String,
}
fn server_identity_path() -> PathBuf {
ServerConfig::default_server_data_root().join("identity.toml")
}
fn read_server_identity_file(
path: &Path,
) -> Result<Option<ServerIdentityFile>, Box<dyn std::error::Error>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)?;
Ok(Some(toml::from_str(&contents)?))
}
fn write_server_identity_file(
path: &Path,
identity: &ServerIdentityFile,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(identity)?;
write_secret_file(path, contents.as_bytes())?;
Ok(())
}
fn write_secret_file(path: &Path, contents: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)?;
use std::io::Write as _;
file.write_all(contents)?;
}
#[cfg(not(unix))]
{
std::fs::write(path, contents)?;
}
Ok(())
}
fn public_identity_view(identity: &RuntimeIdentityMaterial) -> PublicIdentityView {
PublicIdentityView {
identity_id: identity.identity_id.clone(),
public_key: identity.public_key.clone(),
}
}
fn run_identity_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut args = VecDeque::from(args);
let subcommand = args
.pop_front()
.ok_or_else(|| CliError("identity requires `init` or `show`".to_string()))?;
match subcommand.as_str() {
"init" => {
let mut server_id = None;
let mut replace = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
"--replace" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
replace = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown identity init argument `{flag}`"
))));
}
}
}
let server_id = server_id
.ok_or_else(|| CliError("identity init requires --server-id".to_string()))?;
let path = server_identity_path();
if read_server_identity_file(&path)?.is_some() && !replace {
return Err(Box::new(CliError(format!(
"server identity already exists at {}; pass --replace to rotate it",
path.display()
))));
}
let identity = RuntimeIdentityMaterial::generate(server_id)?;
write_server_identity_file(
&path,
&ServerIdentityFile {
identity: identity.clone(),
},
)?;
println!("server_id={}", identity.identity_id);
println!("public_key={}", identity.public_key);
println!("identity_file={}", path.display());
Ok(())
}
"show" => {
let mut json = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--json" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
json = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown identity show argument `{flag}`"
))));
}
}
}
let path = server_identity_path();
let identity = read_server_identity_file(&path)?.ok_or_else(|| {
CliError(format!(
"server identity is not initialized at {}",
path.display()
))
})?;
let view = public_identity_view(&identity.identity);
if json {
println!("{}", serde_json::to_string_pretty(&view)?);
} else {
println!("server_id={}", view.identity_id);
println!("public_key={}", view.public_key);
println!("identity_file={}", path.display());
}
Ok(())
}
_ => Err(Box::new(CliError(format!(
"unknown identity subcommand `{subcommand}`"
)))),
}
}
fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut args = VecDeque::from(args);
let subcommand = args
.pop_front()
.ok_or_else(|| CliError("trust-runtime requires `add`, `list`, or `revoke`".to_string()))?;
let database_path = ServerConfig::default_server_database_path();
if let Some(parent) = database_path.parent() {
std::fs::create_dir_all(parent)?;
}
let store = SqliteWorkspaceStore::open(&database_path)?;
match subcommand.as_str() {
"add" => {
let mut runtime_id = None;
let mut workspace_id = None;
let mut base_url = None;
let mut public_key = None;
let mut display_name = None;
let mut replace = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--runtime-id" => {
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--base-url" | "--endpoint" => {
base_url = Some(take_value(&flag, inline_value, &mut args)?)
}
"--public-key" => {
public_key = Some(take_value(&flag, inline_value, &mut args)?)
}
"--display-name" => {
display_name = Some(take_value(&flag, inline_value, &mut args)?)
}
"--replace" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
replace = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime add argument `{flag}`"
))));
}
}
}
let runtime_id = runtime_id
.ok_or_else(|| CliError("trust-runtime add requires --runtime-id".to_string()))?;
let workspace_id = workspace_id
.ok_or_else(|| CliError("trust-runtime add requires --workspace-id".to_string()))?;
if !store
.list_workspaces()?
.iter()
.any(|workspace| workspace.workspace_id == workspace_id)
{
return Err(Box::new(CliError(format!(
"Workspace `{workspace_id}` is not registered"
))));
}
let base_url = base_url
.ok_or_else(|| CliError("trust-runtime add requires --base-url".to_string()))?;
let public_key = public_key
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
decode_public_key(&public_key)?;
let now = Utc::now().to_rfc3339();
let outcome = store.upsert_workspace_runtime_binding(
WorkspaceRuntimeBinding {
workspace_id: workspace_id.clone(),
runtime_id: runtime_id.clone(),
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
base_url,
public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
created_at: now.clone(),
updated_at: now,
revoked_at: None,
},
replace,
)?;
println!("workspace_id={workspace_id}");
println!("runtime_id={runtime_id}");
println!(
"result={}",
match outcome {
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Created =>
"created",
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged =>
"unchanged",
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Replaced =>
"replaced",
}
);
println!("server_db={}", database_path.display());
Ok(())
}
"list" => {
let mut workspace_id = None;
let mut json = false;
let mut include_revoked = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--json" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
json = true;
}
"--include-revoked" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
include_revoked = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime list argument `{flag}`"
))));
}
}
}
let workspace_id = workspace_id.ok_or_else(|| {
CliError("trust-runtime list requires --workspace-id".to_string())
})?;
let records = store.list_workspace_runtime_bindings(&workspace_id, include_revoked)?;
if json {
println!("{}", serde_json::to_string_pretty(&records)?);
} else {
for runtime in records {
println!(
"workspace_id={} runtime_id={} base_url={} public_key_fingerprint={} revoked_at={}",
runtime.workspace_id,
runtime.runtime_id,
runtime.base_url,
runtime.public_key_fingerprint,
runtime.revoked_at.unwrap_or_default()
);
}
}
Ok(())
}
"revoke" => {
let mut workspace_id = None;
let mut runtime_id = None;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--runtime-id" => {
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime revoke argument `{flag}`"
))));
}
}
}
let workspace_id = workspace_id.ok_or_else(|| {
CliError("trust-runtime revoke requires --workspace-id".to_string())
})?;
let runtime_id = runtime_id.ok_or_else(|| {
CliError("trust-runtime revoke requires --runtime-id".to_string())
})?;
let now = Utc::now().to_rfc3339();
if !store.revoke_workspace_runtime_binding(&workspace_id, &runtime_id, &now)? {
return Err(Box::new(CliError(format!(
"trusted runtime `{runtime_id}` is not registered or is already revoked"
))));
}
println!("revoked_runtime_id={runtime_id}");
Ok(())
}
_ => Err(Box::new(CliError(format!(
"unknown trust-runtime subcommand `{subcommand}`"
)))),
}
}
fn split_flag_value(arg: String) -> Result<(String, Option<String>), CliError> {
if let Some((flag, value)) = arg.split_once('=') {
if flag.is_empty() {
return Err(CliError("empty flag name".to_string()));
}
Ok((flag.to_string(), Some(value.to_string())))
} else {
Ok((arg, None))
}
}
fn take_value(
flag: &str,
inline_value: Option<String>,
args: &mut VecDeque<String>,
) -> Result<String, CliError> {
if let Some(value) = inline_value {
return Ok(value);
}
args.pop_front()
.ok_or_else(|| CliError(format!("{flag} requires a value")))
}
fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(), CliError> {
if inline_value.is_some() {
return Err(CliError(format!("{flag} does not accept a value")));
}
Ok(())
}
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> { fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
match command { match command {
SkillsCommand::List(options) => { SkillsCommand::List(options) => {
@@ -519,6 +153,30 @@ fn load_skill_workspace_config(
}) })
} }
fn remote_runtime_config_from_binding(
binding: WorkspaceRuntimeBinding,
) -> Result<Option<RemoteRuntimeConfig>, CliError> {
if binding.runtime_id == EMBEDDED_RUNTIME_ID {
return Ok(None);
}
if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity {
return Err(CliError(format!(
"Runtime binding '{}:{}' still uses removed legacy Server-issued authentication",
binding.workspace_id, binding.runtime_id
)));
}
Ok(Some(
RemoteRuntimeConfig::new(
binding.runtime_id,
binding.display_name,
binding.base_url,
None,
)
.with_workspace_id(binding.workspace_id)
.with_strict_public_egress(true),
))
}
fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>> { fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>> {
if options.help { if options.help {
print_migrate_help(); print_migrate_help();
@@ -639,6 +297,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
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
@@ -647,30 +306,13 @@ fn append_workspace_runtime_sources(
.into_iter() .into_iter()
.flatten() .flatten()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else { for binding in bindings {
if !bindings.is_empty() { let Some(remote) = remote_runtime_config_from_binding(binding)? else {
return Err(Box::new(CliError( continue;
"Runtime bindings are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
)));
}
return Ok(());
}; };
for runtime in bindings {
let auth = RemoteRuntimeAuthConfig {
server_id: server_identity.identity.identity_id.clone(),
server_private_key: server_identity.identity.private_key.clone(),
};
let remote = RemoteRuntimeConfig::new(
runtime.runtime_id.clone(),
runtime.display_name,
runtime.base_url,
None,
)
.with_workspace_id(runtime.workspace_id.clone())
.with_auth(auth);
remote_runtime_sources.retain(|existing| { remote_runtime_sources.retain(|existing| {
existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str()) existing.workspace_id.as_deref() != remote.workspace_id.as_deref()
|| existing.runtime_id != runtime.runtime_id || existing.runtime_id != remote.runtime_id
}); });
remote_runtime_sources.push(remote); remote_runtime_sources.push(remote);
} }
@@ -840,7 +482,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list --workspace-id <WORKSPACE_ID> [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id <WORKSPACE_ID> --runtime-id <RUNTIME_ID>\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" "yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
); );
} }
@@ -952,60 +594,41 @@ mod tests {
"unknown serve option `--frontend=/tmp/web`" "unknown serve option `--frontend=/tmp/web`"
); );
} }
#[test] #[test]
fn server_identity_init_requires_explicit_server_id() { fn runtime_startup_rejects_legacy_server_issuer_bindings() {
let error = run_identity_command(vec!["init".to_string()]).unwrap_err();
assert_eq!(error.to_string(), "identity init requires --server-id");
}
#[test]
fn runtime_binding_requires_explicit_replace_for_changed_authority() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
let store = SqliteWorkspaceStore::open(&path).unwrap();
rusqlite::Connection::open(&path)
.unwrap()
.execute_batch(
"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
VALUES ('owner', 'user', 'owner', 'Owner', '1', '1');
INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at)
VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1');",
)
.unwrap();
let public_key = RuntimeIdentityMaterial::generate("runtime-a")
.unwrap()
.public_key;
let binding = WorkspaceRuntimeBinding { let binding = WorkspaceRuntimeBinding {
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_owned(),
runtime_id: "runtime-a".to_string(), runtime_id: "runtime-a".to_owned(),
display_name: "Runtime A".to_string(), display_name: "Runtime A".to_owned(),
base_url: "http://127.0.0.1:18080".to_string(), base_url: "https://runtime.example.test".to_owned(),
public_key, public_key: "unused".to_owned(),
public_key_fingerprint: String::new(), public_key_fingerprint: "unused".to_owned(),
binding_revision: 1, binding_revision: 1,
created_at: "2026-07-26T00:00:00Z".to_string(), state: WorkspaceRuntimeBindingState::Verified,
updated_at: "2026-07-26T00:00:00Z".to_string(), authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None, revoked_at: None,
}; };
store let error = remote_runtime_config_from_binding(binding)
.upsert_workspace_runtime_binding(binding.clone(), false) .unwrap_err()
.unwrap(); .to_string();
assert!(matches!( assert_eq!(
store error,
.upsert_workspace_runtime_binding(binding.clone(), false) "Runtime binding 'workspace-a:runtime-a' still uses removed legacy Server-issued authentication"
.unwrap(), );
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged }
));
let mut changed = binding; #[test]
changed.base_url = "http://127.0.0.1:18081".to_string(); fn parse_cli_rejects_removed_server_global_runtime_trust_commands() {
assert!( for command in ["identity", "trust-runtime"] {
store let error = parse_command(&[command.to_owned()]).unwrap_err();
.upsert_workspace_runtime_binding(changed.clone(), false) assert_eq!(
.is_err() error.to_string(),
format!("unknown command `{command}`; expected `migrate`, `skills`, or `serve`")
); );
store }
.upsert_workspace_runtime_binding(changed, true)
.unwrap();
} }
} }
@@ -10,12 +10,11 @@ use protocol::subscription::{
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode, SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode,
}; };
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims}; use tokio_tungstenite::{client_async_tls_with_config, connect_async};
use crate::hosts::RemoteRuntimeConfig; use crate::hosts::{RemoteRuntimeConfig, resolve_strict_remote_runtime_endpoint};
const DOWNSTREAM_QUEUE_CAPACITY: usize = 256; const DOWNSTREAM_QUEUE_CAPACITY: usize = 256;
const RECONNECT_DELAY: Duration = Duration::from_millis(100); const RECONNECT_DELAY: Duration = Duration::from_millis(100);
@@ -918,10 +917,43 @@ async fn connect_runtime(
.map_err(|error| format!("invalid Runtime authorization header: {error}"))?, .map_err(|error| format!("invalid Runtime authorization header: {error}"))?,
); );
} }
connect_async(request) if config.strict_public_egress {
let base_url = config.base_url.clone();
let (_, addresses) =
tokio::task::spawn_blocking(move || resolve_strict_remote_runtime_endpoint(&base_url))
.await .await
.map_err(|_| {
"Runtime subscription endpoint resolution task failed".to_string()
})??;
let stream = tokio::time::timeout(config.timeout, async move {
let mut last_error = None;
for address in addresses {
match tokio::net::TcpStream::connect(address).await {
Ok(stream) => return Ok(stream),
Err(error) => last_error = Some(error),
}
}
Err(last_error
.map(|error| error.to_string())
.unwrap_or_else(|| "no validated Runtime address was available".to_string()))
})
.await
.map_err(|_| "Runtime subscription TCP connection timed out".to_string())??;
tokio::time::timeout(
config.timeout,
client_async_tls_with_config(request, stream, None, None),
)
.await
.map_err(|_| "Runtime subscription TLS/WebSocket handshake timed out".to_string())?
.map(|(socket, _)| socket) .map(|(socket, _)| socket)
.map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}")) .map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}"))
} else {
tokio::time::timeout(config.timeout, connect_async(request))
.await
.map_err(|_| "Runtime subscription connection timed out".to_string())?
.map(|(socket, _)| socket)
.map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}"))
}
} }
fn runtime_endpoint(base_url: &str) -> String { fn runtime_endpoint(base_url: &str) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
@@ -935,24 +967,15 @@ fn runtime_endpoint(base_url: &str) -> String {
} }
fn runtime_token( fn runtime_token(
config: &RemoteRuntimeConfig, config: &RemoteRuntimeConfig,
workspace_id: &str, _workspace_id: &str,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
let Some(auth) = config.auth.as_ref() else { if let Some(authorization) = config.workspace_authorization.as_ref() {
return Ok(config.bearer_token.clone()); return authorization
}; .issue("GET", "/v1/protocol/ws", "workers:list", None, &[])
let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key);
let claims = capability_claims(
&auth.server_id,
&config.runtime_id,
workspace_id,
vec!["workers:list".into()],
300,
)
.map_err(|error| error.to_string())?;
signer
.sign(&claims)
.map(Some) .map(Some)
.map_err(|error| error.to_string()) .map_err(|error| error.message);
}
Ok(config.bearer_token.clone())
} }
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) { fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus { *status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
@@ -416,3 +416,19 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
)); ));
server.abort(); server.abort();
} }
#[tokio::test]
async fn strict_runtime_subscription_rejects_private_endpoint_before_websocket_connect() {
let config = RemoteRuntimeConfig::new(
"runtime-private",
"Private Runtime",
"https://169.254.169.254",
None,
)
.with_strict_public_egress(true);
let error = match connect_runtime(&config, "workspace-a").await {
Err(error) => error,
Ok(_) => panic!("private endpoint unexpectedly produced a WebSocket"),
};
assert!(error.contains("endpoint host is not public"), "{error}");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -15
View File
@@ -10,7 +10,6 @@ use worker_runtime::auth::{
}; };
use worker_runtime::worker_source::InProcessWorkerMutationProof; use worker_runtime::worker_source::InProcessWorkerMutationProof;
use crate::hosts::RemoteRuntimeConfig;
use crate::server::{ServerConfig, WorkspaceApi}; use crate::server::{ServerConfig, WorkspaceApi};
use crate::store::ControlPlaneStore; use crate::store::ControlPlaneStore;
@@ -55,7 +54,7 @@ pub async fn verify_runtime_request_source_proof_with_store(
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> { ) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
let unverified = decode_runtime_request_source_claims(proof) let unverified = decode_runtime_request_source_claims(proof)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?; .map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, workspace_id)?; let audience = remote_audience(config, workspace_id)?;
let trusted = store let trusted = store
.get_workspace_runtime_binding(workspace_id, &unverified.iss) .get_workspace_runtime_binding(workspace_id, &unverified.iss)
.await .await
@@ -201,7 +200,7 @@ async fn verify_worker_remove_source_with(
PresentedWorkerMutationSourceProof::Remote(token) => { PresentedWorkerMutationSourceProof::Remote(token) => {
let unverified = decode_worker_mutation_source_claims(token) let unverified = decode_worker_mutation_source_claims(token)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?; .map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?; let audience = remote_audience(config, &config.workspace_id)?;
let trusted = store let trusted = store
.get_workspace_runtime_binding(&config.workspace_id, &unverified.iss) .get_workspace_runtime_binding(&config.workspace_id, &unverified.iss)
.await .await
@@ -357,20 +356,19 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
} }
fn remote_audience<'a>( fn remote_audience<'a>(
config: &'a crate::server::ServerConfig, config: &'a ServerConfig,
runtime_id: &str,
workspace_id: &str, workspace_id: &str,
) -> Result<std::borrow::Cow<'a, str>, WorkerMutationSourceProofError> { ) -> Result<&'a str, WorkerMutationSourceProofError> {
if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID {
return Ok(std::borrow::Cow::Owned(format!("embedded:{workspace_id}")));
}
config config
.remote_runtime_sources .backend_base_url
.iter() .as_deref()
.find(|runtime| runtime.runtime_id == runtime_id) .map(str::trim)
.and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref()) .filter(|audience| !audience.is_empty())
.map(|auth| std::borrow::Cow::Borrowed(auth.server_id.as_str())) .ok_or_else(|| {
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust) WorkerMutationSourceProofError::Authority(format!(
"Backend public URL is unavailable for Workspace `{workspace_id}` source proof verification"
))
})
} }
fn validate_in_process_claims( fn validate_in_process_claims(
+224 -12
View File
@@ -11,6 +11,9 @@ use crate::repository_source::{parse_repository_source, repository_source_finger
use crate::store::{ use crate::store::{
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord, ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
}; };
use crate::workspace_signing_identity::{
WorkspaceSigningIdentityService, WorkspaceSigningMaterialStore,
};
use crate::{Error, Result}; use crate::{Error, Result};
const MAX_DISPLAY_NAME_BYTES: usize = 200; const MAX_DISPLAY_NAME_BYTES: usize = 200;
@@ -45,11 +48,21 @@ pub struct WorkspaceCreateResult {
#[derive(Clone)] #[derive(Clone)]
pub struct WorkspaceCatalogService { pub struct WorkspaceCatalogService {
store: Arc<dyn ControlPlaneStore>, store: Arc<dyn ControlPlaneStore>,
signing_identities: WorkspaceSigningIdentityService,
} }
impl WorkspaceCatalogService { impl WorkspaceCatalogService {
pub fn new(store: Arc<dyn ControlPlaneStore>) -> Self { pub fn new(
Self { store } store: Arc<dyn ControlPlaneStore>,
signing_materials: Arc<dyn WorkspaceSigningMaterialStore>,
) -> Self {
Self {
signing_identities: WorkspaceSigningIdentityService::new(
store.clone(),
signing_materials,
),
store,
}
} }
pub fn is_empty(&self) -> Result<bool> { pub fn is_empty(&self) -> Result<bool> {
@@ -118,7 +131,7 @@ impl WorkspaceCatalogService {
.map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string())) .map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string()))
}) })
.transpose()?; .transpose()?;
let workspace_id = requested_workspace_id let proposed_workspace_id = requested_workspace_id
.clone() .clone()
.unwrap_or_else(|| Uuid::now_v7().to_string()); .unwrap_or_else(|| Uuid::now_v7().to_string());
let fingerprint = workspace_create_fingerprint( let fingerprint = workspace_create_fingerprint(
@@ -130,9 +143,16 @@ impl WorkspaceCatalogService {
&default_ref, &default_ref,
); );
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let result = self let (signing_identity, identity_provisioning_operation_key) =
.store self.signing_identities.prepare_workspace_creation(
.create_workspace_bootstrap(&WorkspaceBootstrapRecord { &operation_key,
&fingerprint,
&proposed_workspace_id,
&owner_account_id,
)?;
let workspace_id = signing_identity.workspace_id.clone();
let result = self.store.create_workspace_bootstrap(
&WorkspaceBootstrapRecord {
operation_key, operation_key,
request_fingerprint: fingerprint.clone(), request_fingerprint: fingerprint.clone(),
workspace: WorkspaceRecord { workspace: WorkspaceRecord {
@@ -158,7 +178,10 @@ impl WorkspaceCatalogService {
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}, },
})?; },
&signing_identity,
&identity_provisioning_operation_key,
)?;
Ok(WorkspaceCreateResult { Ok(WorkspaceCreateResult {
workspace: result.workspace, workspace: result.workspace,
repository: result.repository, repository: result.repository,
@@ -214,10 +237,45 @@ fn workspace_create_fingerprint(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*; use super::*;
use crate::store::{AccountRecord, SqliteWorkspaceStore}; use crate::store::{AccountRecord, SqliteWorkspaceStore};
use crate::workspace_signing_identity::{
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
WorkspaceSigningPrivateMaterial, identity_error,
};
use workspace_api::RepositorySourceKind; use workspace_api::RepositorySourceKind;
struct FailFirstMaterialWrite {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
fail: AtomicBool,
}
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
self.inner.load(material_ref)
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
if self.fail.swap(false, Ordering::SeqCst) {
return Err(identity_error(
"workspace_signing_identity_material_io_failed",
"injected private material write failure",
));
}
self.inner.put_if_absent(material_ref, material)
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.inner.delete(material_ref)
}
}
fn git_repository() -> tempfile::TempDir { fn git_repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap(); std::fs::create_dir(dir.path().join(".git")).unwrap();
@@ -243,7 +301,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn create_is_atomic_and_exact_retries_converge() { async fn create_is_atomic_and_exact_retries_converge() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let service = WorkspaceCatalogService::new(store.clone()); let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository(); let repository = git_repository();
let request = WorkspaceCreateRequest { let request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(), operation_key: "request-1".to_string(),
@@ -268,6 +331,14 @@ mod tests {
replayed.workspace.workspace_id replayed.workspace.workspace_id
); );
assert_eq!(store.list_workspaces().unwrap().len(), 1); assert_eq!(store.list_workspaces().unwrap().len(), 1);
let signing_identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.expect("new Workspace signing identity");
assert_eq!(signing_identity.state, "active");
assert_eq!(signing_identity.algorithm, "ed25519");
assert!(signing_identity.public_key.is_some());
assert!(signing_identity.public_key_fingerprint.is_some());
assert_eq!( assert_eq!(
store store
.list_repositories(&created.workspace.workspace_id) .list_repositories(&created.workspace.workspace_id)
@@ -283,11 +354,137 @@ mod tests {
); );
} }
#[tokio::test]
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "material-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let reserved_key = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'",
[],
|row| row.get::<_, String>(0),
)
.map_err(Error::from)
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
}
#[tokio::test]
async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "db-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_create_identity_audit
BEFORE INSERT ON workspace_signing_identity_audit
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
)?;
Ok(())
})
.unwrap();
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let (reserved_key, material_ref) = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'",
[],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.map_err(Error::from)
})
.unwrap();
assert!(materials.load(&material_ref).unwrap().is_some());
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
Ok(())
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(identity.state, "active");
}
#[tokio::test] #[tokio::test]
async fn idempotency_key_reuse_with_different_payload_is_rejected() { async fn idempotency_key_reuse_with_different_payload_is_rejected() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref()); let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store); let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository(); let repository = git_repository();
let mut request = WorkspaceCreateRequest { let mut request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(), operation_key: "request-1".to_string(),
@@ -338,7 +535,12 @@ mod tests {
updated_at: "2026-07-03T00:00:00Z".to_string(), updated_at: "2026-07-03T00:00:00Z".to_string(),
}) })
.unwrap(); .unwrap();
let service = WorkspaceCatalogService::new(store); let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository(); let repository = git_repository();
let error = service let error = service
.create( .create(
@@ -373,7 +575,12 @@ mod tests {
updated_at: "2026-07-03T00:00:00Z".to_string(), updated_at: "2026-07-03T00:00:00Z".to_string(),
}) })
.unwrap(); .unwrap();
let service = WorkspaceCatalogService::new(store); let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository_a = git_repository(); let repository_a = git_repository();
let repository_b = git_repository(); let repository_b = git_repository();
let created_a = service let created_a = service
@@ -425,7 +632,12 @@ mod tests {
fn remote_repository_creation_persists_typed_source_without_auth_metadata() { fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref()); let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store.clone()); let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let result = service let result = service
.create( .create(
WorkspaceCreateRequest { WorkspaceCreateRequest {
@@ -80,6 +80,10 @@ 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_signing_identities",
"workspace_signing_identity_audit",
"workspace_signing_identity_provisioning_operations",
"workspace_worker_retention_policies", "workspace_worker_retention_policies",
"workspace_worker_retention_policy_revisions", "workspace_worker_retention_policy_revisions",
]; ];
@@ -0,0 +1,880 @@
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use chrono::{SecondsFormat, Utc};
use ring::signature::KeyPair;
use serde::{Deserialize, Serialize};
use worker_runtime::auth::{RuntimeIdentityMaterial, encode_public_key};
use worker_runtime::workspace_issuer::{
WorkspaceCapabilityClaims, WorkspaceCapabilityVerificationError,
assemble_workspace_capability_token, workspace_capability_signing_input,
};
use zeroize::Zeroize;
use crate::store::{
ControlPlaneStore, WorkspaceSigningIdentityActivation,
WorkspaceSigningIdentityProvisioningOperation, WorkspaceSigningIdentityRecord,
};
use crate::{Error, Result};
pub const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519";
pub const WORKSPACE_SIGNING_IDENTITY_REVISION: u64 = 1;
const MATERIAL_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceSigningPrivateMaterial {
version: u32,
workspace_id: String,
key_id: String,
revision: u64,
private_key: String,
}
impl fmt::Debug for WorkspaceSigningPrivateMaterial {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("WorkspaceSigningPrivateMaterial")
.field("version", &self.version)
.field("workspace_id", &self.workspace_id)
.field("key_id", &self.key_id)
.field("revision", &self.revision)
.field("private_key", &"[REDACTED]")
.finish()
}
}
impl Drop for WorkspaceSigningPrivateMaterial {
fn drop(&mut self) {
self.private_key.zeroize();
}
}
impl WorkspaceSigningPrivateMaterial {
pub fn generate(workspace_id: &str, key_id: &str) -> Result<Self> {
let material = RuntimeIdentityMaterial::generate(key_id.to_string()).map_err(|error| {
identity_error(
"workspace_signing_identity_generation_failed",
format!("failed to generate Workspace signing identity: {error}"),
)
})?;
Ok(Self {
version: MATERIAL_SCHEMA_VERSION,
workspace_id: workspace_id.to_string(),
key_id: key_id.to_string(),
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
private_key: material.private_key,
})
}
fn signing_key(
&self,
expected_workspace_id: &str,
expected_key_id: &str,
expected_revision: u64,
) -> Result<ring::signature::Ed25519KeyPair> {
if self.version != MATERIAL_SCHEMA_VERSION
|| self.workspace_id != expected_workspace_id
|| self.key_id != expected_key_id
|| self.revision != expected_revision
{
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match its persisted metadata",
));
}
RuntimeIdentityMaterial {
identity_id: self.key_id.clone(),
public_key: String::new(),
private_key: self.private_key.clone(),
}
.signing_key()
.map_err(|_| {
identity_error(
"workspace_signing_identity_material_corrupt",
"Workspace signing private material is corrupt",
)
})
}
pub fn validate_and_public_key(
&self,
expected_workspace_id: &str,
expected_key_id: &str,
expected_revision: u64,
) -> Result<String> {
let signing_key =
self.signing_key(expected_workspace_id, expected_key_id, expected_revision)?;
Ok(encode_public_key(signing_key.public_key().as_ref()))
}
}
pub trait WorkspaceSigningMaterialStore: Send + Sync {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>>;
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial>;
fn delete(&self, material_ref: &str) -> Result<()>;
}
#[derive(Clone)]
pub struct WorkspaceSigningIdentityService {
store: Arc<dyn ControlPlaneStore>,
materials: Arc<dyn WorkspaceSigningMaterialStore>,
}
impl WorkspaceSigningIdentityService {
pub fn new(
store: Arc<dyn ControlPlaneStore>,
materials: Arc<dyn WorkspaceSigningMaterialStore>,
) -> Self {
Self { store, materials }
}
pub fn prepare_workspace_creation(
&self,
workspace_create_operation_key: &str,
request_fingerprint: &str,
proposed_workspace_id: &str,
actor_account_id: &str,
) -> Result<(WorkspaceSigningIdentityActivation, String)> {
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let proposed_key_id = format!("WK-{}", uuid::Uuid::now_v7().simple());
let proposed_material_ref = format!("workspace-signing/{proposed_workspace_id}/ed25519-v1");
let operation_key = format!("workspace-create:{workspace_create_operation_key}");
let operation = self.store.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: operation_key.clone(),
request_fingerprint: request_fingerprint.to_string(),
operation_kind: "workspace_create".to_string(),
workspace_id: proposed_workspace_id.to_string(),
key_id: proposed_key_id,
private_material_ref: proposed_material_ref,
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
actor_account_id: actor_account_id.to_string(),
state: "pending".to_string(),
created_at: now,
completed_at: None,
},
)?;
let activation = self.prepare_material(&operation)?;
Ok((activation, operation.operation_key))
}
pub fn provision_existing(
&self,
workspace_id: &str,
actor_account_id: &str,
) -> Result<WorkspaceSigningIdentityRecord> {
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" {
self.validate_active_material(&identity)?;
return Ok(identity);
}
if identity.state != "pending_provisioning" {
return Err(identity_error(
"workspace_signing_identity_state_invalid",
"Workspace signing identity state is invalid",
));
}
let operation_key = format!(
"existing-workspace:{workspace_id}:revision-{}",
identity.revision
);
let request_fingerprint =
provisioning_fingerprint(workspace_id, &identity.key_id, identity.revision);
let operation = self.store.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: operation_key.clone(),
request_fingerprint,
operation_kind: "existing_workspace".to_string(),
workspace_id: workspace_id.to_string(),
key_id: identity.key_id.clone(),
private_material_ref: identity.private_material_ref.clone(),
revision: identity.revision,
actor_account_id: actor_account_id.to_string(),
state: "pending".to_string(),
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
completed_at: None,
},
)?;
let activation = self.prepare_material(&operation)?;
self.store.activate_workspace_signing_identity(
&activation,
&operation.operation_key,
&operation.actor_account_id,
)
}
pub fn get_validated(&self, workspace_id: &str) -> Result<WorkspaceSigningIdentityRecord> {
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" {
self.validate_active_material(&identity)?;
}
Ok(identity)
}
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
let identity = self.get_validated(workspace_id)?;
if identity.state != "active" {
return Err(identity_error(
"workspace_signing_identity_not_provisioned",
"Workspace signing identity is not provisioned",
));
}
let material = self
.materials
.load(&identity.private_material_ref)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing",
)
})?;
let signing_key =
material.signing_key(workspace_id, &identity.key_id, identity.revision)?;
Ok(signing_key.sign(payload).as_ref().to_vec())
}
pub fn issue_workspace_capability(
&self,
workspace_id: &str,
claims: &WorkspaceCapabilityClaims,
) -> Result<String> {
let input = workspace_capability_signing_input(claims).map_err(capability_error)?;
let signature = self.sign(workspace_id, input.bytes())?;
assemble_workspace_capability_token(input, &signature).map_err(capability_error)
}
pub fn delete_material(&self, workspace_id: &str) -> Result<()> {
if let Some(identity) = self.store.get_workspace_signing_identity(workspace_id)? {
self.materials.delete(&identity.private_material_ref)?;
}
Ok(())
}
fn prepare_material(
&self,
operation: &WorkspaceSigningIdentityProvisioningOperation,
) -> Result<WorkspaceSigningIdentityActivation> {
let material = match self.materials.load(&operation.private_material_ref)? {
Some(material) => material,
None => {
let generated = WorkspaceSigningPrivateMaterial::generate(
&operation.workspace_id,
&operation.key_id,
)?;
self.materials
.put_if_absent(&operation.private_material_ref, &generated)?
}
};
let public_key = material.validate_and_public_key(
&operation.workspace_id,
&operation.key_id,
operation.revision,
)?;
let public_key_fingerprint = public_key_fingerprint(&public_key)?;
Ok(WorkspaceSigningIdentityActivation {
workspace_id: operation.workspace_id.clone(),
key_id: operation.key_id.clone(),
public_key,
public_key_fingerprint,
private_material_ref: operation.private_material_ref.clone(),
revision: operation.revision,
provisioned_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
})
}
fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> {
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 public_key = material.validate_and_public_key(
&identity.workspace_id,
&identity.key_id,
identity.revision,
)?;
let fingerprint = public_key_fingerprint(&public_key)?;
if identity.public_key.as_deref() != Some(public_key.as_str())
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str())
{
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match public metadata",
));
}
Ok(())
}
}
fn provisioning_fingerprint(workspace_id: &str, key_id: &str, revision: u64) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(workspace_id.as_bytes());
hasher.update([0]);
hasher.update(key_id.as_bytes());
hasher.update([0]);
hasher.update(revision.to_be_bytes());
format!("sha256:{}", hex_lower(&hasher.finalize()))
}
#[derive(Default)]
pub struct InMemoryWorkspaceSigningMaterialStore {
materials: std::sync::Mutex<std::collections::HashMap<String, WorkspaceSigningPrivateMaterial>>,
}
impl WorkspaceSigningMaterialStore for InMemoryWorkspaceSigningMaterialStore {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
Ok(self
.materials
.lock()
.expect("identity material store lock")
.get(material_ref)
.cloned())
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
let mut materials = self.materials.lock().expect("identity material store lock");
Ok(materials
.entry(material_ref.to_string())
.or_insert_with(|| material.clone())
.clone())
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.materials
.lock()
.expect("identity material store lock")
.remove(material_ref);
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct FsWorkspaceSigningMaterialStore {
root: PathBuf,
}
impl FsWorkspaceSigningMaterialStore {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
fn material_path(&self, material_ref: &str) -> Result<PathBuf> {
let relative = Path::new(material_ref);
if relative.as_os_str().is_empty()
|| relative.is_absolute()
|| relative.components().any(|component| {
!matches!(component, Component::Normal(_))
|| component.as_os_str().to_string_lossy().starts_with('.')
})
{
return Err(identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference is invalid",
));
}
Ok(self.root.join(relative).with_extension("json"))
}
}
impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
let path = self.material_path(material_ref)?;
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(material_io_error("read", error)),
};
serde_json::from_slice(&bytes).map(Some).map_err(|_| {
identity_error(
"workspace_signing_identity_material_corrupt",
"Workspace signing private material is corrupt",
)
})
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
let path = self.material_path(material_ref)?;
let parent = path.parent().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference has no parent",
)
})?;
ensure_private_tree(&self.root, parent)?;
let mut bytes = serde_json::to_vec(material).map_err(|_| {
identity_error(
"workspace_signing_identity_material_encode_failed",
"Workspace signing private material could not be encoded",
)
})?;
let temporary = parent.join(format!(
".workspace-signing-{}.tmp",
uuid::Uuid::now_v7().simple()
));
let write_result = (|| -> Result<()> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temporary)
.map_err(|error| material_io_error("create", error))?;
file.write_all(&bytes)
.and_then(|()| file.sync_all())
.map_err(|error| material_io_error("write", error))?;
match fs::hard_link(&temporary, &path) {
Ok(()) => sync_directory(parent),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(material_io_error("publish", error)),
}
})();
bytes.zeroize();
let cleanup_result = match fs::remove_file(&temporary) {
Ok(()) => sync_directory(parent),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(material_io_error("remove temporary", error)),
};
write_result?;
cleanup_result?;
self.load(material_ref)?.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing after publication",
)
})
}
fn delete(&self, material_ref: &str) -> Result<()> {
let path = self.material_path(material_ref)?;
match fs::remove_file(&path) {
Ok(()) => {
let parent = path.parent().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference has no parent",
)
})?;
sync_directory(parent)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(material_io_error("delete", error)),
}
}
}
fn ensure_private_tree(root: &Path, leaf: &Path) -> Result<()> {
ensure_private_directory(root)?;
if let Some(parent) = root.parent() {
sync_directory(parent)?;
}
let relative = leaf.strip_prefix(root).map_err(|_| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material path escapes its authority root",
)
})?;
let mut current = root.to_path_buf();
for component in relative.components() {
let parent = current.clone();
current.push(component);
ensure_private_directory(&current)?;
sync_directory(&parent)?;
}
Ok(())
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> Result<()> {
std::fs::File::open(path)
.and_then(|directory| directory.sync_all())
.map_err(|error| material_io_error("synchronize directory", error))
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> Result<()> {
Err(identity_error(
"workspace_signing_identity_durable_publish_unsupported",
"Workspace signing private material durable publication is unsupported on this platform",
))
}
fn ensure_private_directory(path: &Path) -> Result<()> {
fs::create_dir_all(path).map_err(|error| material_io_error("create directory", error))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
.map_err(|error| material_io_error("set directory permissions", error))?;
}
Ok(())
}
pub fn workspace_signing_material_root(database_path: &Path) -> PathBuf {
database_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("workspace-signing-identities")
}
pub fn public_key_fingerprint(public_key: &str) -> Result<String> {
let bytes = worker_runtime::auth::decode_public_key(public_key).map_err(|_| {
identity_error(
"workspace_signing_identity_public_key_invalid",
"Workspace signing public key is invalid",
)
})?;
use sha2::{Digest, Sha256};
Ok(format!("sha256:{}", hex_lower(&Sha256::digest(bytes))))
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
fn material_io_error(action: &str, error: std::io::Error) -> Error {
identity_error(
"workspace_signing_identity_material_io_failed",
format!("failed to {action} Workspace signing private material: {error}"),
)
}
fn capability_error(error: WorkspaceCapabilityVerificationError) -> Error {
identity_error(
"workspace_capability_issuance_failed",
format!("failed to issue Workspace capability: {error}"),
)
}
pub fn identity_error(code: impl Into<String>, message: impl Into<String>) -> Error {
Error::WorkspaceSigningIdentity {
code: code.into(),
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*;
struct FailFirstMaterialWrite {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
fail: AtomicBool,
}
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
self.inner.load(material_ref)
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
if self.fail.swap(false, Ordering::SeqCst) {
return Err(identity_error(
"workspace_signing_identity_material_io_failed",
"injected private material write failure",
));
}
self.inner.put_if_absent(material_ref, material)
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.inner.delete(material_ref)
}
}
#[tokio::test]
async fn existing_workspace_provisioning_is_audited_idempotent_and_fails_closed_when_missing() {
use crate::store::{AccountRecord, SqliteWorkspaceStore, WorkspaceRecord};
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
store
.upsert_account(&AccountRecord {
account_id: "account-1".to_string(),
kind: "user".to_string(),
handle: "owner".to_string(),
display_name: "Owner".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-1".to_string(),
owner_account_id: "account-1".to_string(),
display_name: "Workspace".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.await
.unwrap();
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let failing_service = WorkspaceSigningIdentityService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
assert_eq!(
failing_service.get_validated("workspace-1").unwrap().state,
"pending_provisioning"
);
let error = failing_service
.provision_existing("workspace-1", "account-1")
.unwrap_err();
assert!(matches!(
error,
Error::WorkspaceSigningIdentity { ref code, .. }
if code == "workspace_signing_identity_material_io_failed"
));
assert_eq!(
store
.get_workspace_signing_identity("workspace-1")
.unwrap()
.unwrap()
.state,
"pending_provisioning"
);
drop(failing_service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let provisioned = service
.provision_existing("workspace-1", "account-1")
.unwrap();
assert_eq!(provisioned.state, "active");
assert!(provisioned.public_key.is_some());
let payload = b"Workspace authority proof";
let signature = service.sign("workspace-1", payload).unwrap();
let public_key =
worker_runtime::auth::decode_public_key(provisioned.public_key.as_deref().unwrap())
.unwrap();
ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key)
.verify(payload, &signature)
.unwrap();
assert_eq!(
service
.provision_existing("workspace-1", "account-1")
.unwrap(),
provisioned
);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_signing_identity_audit WHERE workspace_id = 'workspace-1'",
[],
|row| row.get::<_, i64>(0),
)?,
1
);
Ok(())
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-2".to_string(),
owner_account_id: "account-1".to_string(),
display_name: "Workspace 2".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.await
.unwrap();
let pending = store
.get_workspace_signing_identity("workspace-2")
.unwrap()
.unwrap();
let operation = store
.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: "existing-workspace:workspace-2:revision-1".to_string(),
request_fingerprint: provisioning_fingerprint(
"workspace-2",
&pending.key_id,
pending.revision,
),
operation_kind: "existing_workspace".to_string(),
workspace_id: "workspace-2".to_string(),
key_id: pending.key_id.clone(),
private_material_ref: pending.private_material_ref.clone(),
revision: pending.revision,
actor_account_id: "account-1".to_string(),
state: "pending".to_string(),
created_at: "1".to_string(),
completed_at: None,
},
)
.unwrap();
let activation = service.prepare_material(&operation).unwrap();
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_signing_identity_audit
BEFORE INSERT ON workspace_signing_identity_audit
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
)?;
Ok(())
})
.unwrap();
assert!(
store
.activate_workspace_signing_identity(
&activation,
&operation.operation_key,
"account-1",
)
.is_err()
);
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_signing_identity_audit;")?;
Ok(())
})
.unwrap();
assert_eq!(
store
.get_workspace_signing_identity("workspace-2")
.unwrap()
.unwrap()
.state,
"pending_provisioning"
);
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let recovered = restarted
.provision_existing("workspace-2", "account-1")
.unwrap();
assert_eq!(recovered.key_id, activation.key_id);
assert_eq!(
recovered.public_key_fingerprint.as_deref(),
Some(activation.public_key_fingerprint.as_str())
);
materials.delete(&provisioned.private_material_ref).unwrap();
let error = restarted.get_validated("workspace-1").unwrap_err();
assert!(matches!(
error,
Error::WorkspaceSigningIdentity { ref code, .. }
if code == "workspace_signing_identity_material_missing"
));
}
#[test]
fn file_store_round_trips_private_material_without_overwrite() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
let first = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let first_public = first.validate_and_public_key("ws-1", "WK-1", 1).unwrap();
let persisted = store.put_if_absent("ws-1/ed25519-v1", &first).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(temp.path().join("identities"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
fs::metadata(store.material_path("ws-1/ed25519-v1").unwrap())
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
assert_eq!(
persisted
.validate_and_public_key("ws-1", "WK-1", 1)
.unwrap(),
first_public
);
let second = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let persisted = store.put_if_absent("ws-1/ed25519-v1", &second).unwrap();
assert_eq!(
persisted
.validate_and_public_key("ws-1", "WK-1", 1)
.unwrap(),
first_public
);
}
#[test]
fn corrupt_and_cross_workspace_material_fail_closed() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
store.put_if_absent("ws-1/ed25519-v1", &material).unwrap();
let loaded = store.load("ws-1/ed25519-v1").unwrap().unwrap();
assert!(loaded.validate_and_public_key("ws-2", "WK-1", 1).is_err());
fs::write(store.material_path("ws-1/ed25519-v1").unwrap(), b"not json").unwrap();
assert!(store.load("ws-1/ed25519-v1").is_err());
}
}
+2
View File
@@ -24,6 +24,8 @@ Responsibilities are split as follows:
The Backend can project Runtime and Worker state, but it should not become a hidden filesystem/runtime implementation. Runtime observations should be reconstructable from Runtime APIs and committed Backend records. The Backend can project Runtime and Worker state, but it should not become a hidden filesystem/runtime implementation. Runtime observations should be reconstructable from Runtime APIs and committed Backend records.
Remote Runtime authentication follows the same Workspace boundary. The Server signs each Runtime request with the target Workspace signing identity, and the Runtime verifies it against the installed Workspace issuer bundle. Runtime-to-Server source proof is signed by the Runtime identity and uses the bundle's Backend URL as audience. Server-global signing identities, Runtime-side global Server trust, and static bearer fallback are not Remote Workspace authority. Provisioning and rotation are described in [Workspace ↔ Runtime authentication](../development/server-runtime-auth.md).
## Docker image layout ## Docker image layout
Docker images are built through Nix `dockerTools.buildImage`, not through a root Dockerfile. Docker images are built through Nix `dockerTools.buildImage`, not through a root Dockerfile.
+9 -27
View File
@@ -4,35 +4,15 @@ This repository is developed with Yoi itself. Dogfooding is valuable because it
## Pre-restart gate ## Pre-restart gate
Never use the live dogfood Server or Runtime as the first startup test for a new Never use the live dogfood Server or Runtime as the first startup test for a new binary. A dogfood restart is allowed only after this sequence succeeds:
binary. A dogfood restart is allowed only after this sequence succeeds:
1. Build the production entrypoints: 1. Build the production entrypoints: `cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
`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 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.
`cargo fmt --all -- --check` and `git diff --check HEAD`. 4. Have an external supervisor or operator restart Server and Runtime at the same generation. A Worker hosted by the target Runtime must never terminate its own Runtime.
3. Run `scripts/isolated-startup-smoke.sh` from an external shell/process. 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.
4. Inspect any failed run's retained `/tmp/yoi-isolated-startup-smoke.*` logs;
do not restart dogfood until the cause is fixed and the smoke passes.
5. Have an external supervisor or operator restart Server and Runtime. A Worker
hosted by the target Runtime must never terminate its own Runtime.
6. Verify post-restart readiness through the Workspace Runtime projection and a
real restored Worker operation before treating the environment as healthy.
The smoke harness runs the normal `yoi-server` and `yoi-runtime` binaries using 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.
separate `HOME`, `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, temporary Git repository,
Server database, Runtime fs store, identity/trust material, and non-dogfood
ports. It fails if either port is already occupied, if state escapes the
temporary root, if a process exits unexpectedly, if Runtime readiness is not
visible through Server, or if startup logs contain a panic, migration collision,
or Worker execution restore failure. It also proves that a listening Server
without its configured Runtime is not readiness and restarts the isolated
Runtime once to exercise persistence reopen.
Override `YOI_SMOKE_SERVER_BIN`, `YOI_SMOKE_RUNTIME_BIN`,
`YOI_SMOKE_SERVER_PORT`, or `YOI_SMOKE_RUNTIME_PORT` only when a separate build
or port is intentionally under test. Set `YOI_SMOKE_KEEP=1` to retain successful
artifacts. Failed artifacts are retained automatically.
## What to record ## What to record
@@ -45,6 +25,8 @@ 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 generation, Server schema version, Runtime binding revision, Workspace key generation, and typed HTTP/WebSocket outcomes. A successful document response does not outweigh visible UI, console, or API errors.
## Runtime command caveat ## Runtime command caveat
After rebuilding and restarting during dogfooding, `current_exe()` can point at a deleted binary path. Use typed runtime-command configuration and the development-only `YOI_POD_RUNTIME_COMMAND` executable override rather than reviving shell-command overrides. After rebuilding and restarting during dogfooding, `current_exe()` can point at a deleted binary path. Use typed runtime-command configuration and the development-only `YOI_POD_RUNTIME_COMMAND` executable override rather than reviving shell-command overrides.
+50 -240
View File
@@ -1,266 +1,76 @@
# Server / Runtime manual auth setup # Workspace ↔ Runtime 認証
Workspace Server and Worker Runtime authenticate remote Runtime control traffic with manually exchanged Ed25519 public keys and short-lived Server-signed capability tokens. Yoi の Remote Runtime 認証は Workspace ごとの署名 identity を authority とする。
Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わない。
This is a non-interactive bootstrap flow. Commands fail when required flags are missing, and existing identity/trust records are not overwritten unless `--replace` is passed explicitly. ## Authority
## Authority boundary - Server DB は Workspace ごとの signing identity と Runtime binding を保持する。
- Runtime は `trust-workspace` で受理した `WorkspaceIssuerAuthorizationBundle` を保持する。
- bundle は `workspace_id`、Workspace key id/generation、Workspace public key、Backend URL、許可された Runtime identity を固定する。
- Server → Runtime の各 HTTP / WebSocket request は、対象 Workspace の signing identity で短命な capability token を発行する。
- Runtime は request method、`path_and_query`、body digest、permission、Workspace、Runtime、key generation、expiry、JTI を検証する。
- Runtime → Server の source proof は Runtime identity で署名し、対象 Workspace と bundle の Backend URL を audience に固定する。
- Server は現在の Workspace Runtime binding、Runtime public key、Backend public URL、request target、body digest、permission、expiry、replay state を検証する。
- Workspace Server is the workspace control plane. It owns trusted Runtime records in the Server DB and signs per-request Runtime capability tokens. 旧 Server identity/trust 管理 command と旧 Runtime-side Server trust command、旧 Runtime auth key flags は廃止済みである。これらに相当する Server-global trust を fallback として使ってはならない。
- Runtime owns Worker execution. It does not own a workspace registry or workspace list.
- Runtime API paths remain worker-centric; workspace scope is carried in the signed auth context and enforced by Runtime-side authorization/filtering code.
- Browser/Web clients should talk to Workspace Server, not directly to Runtime.
## Identifiers used in examples ## Provisioning
Replace these values for the deployment: 1. Runtime identity を初期化する。
```text ```sh
SERVER_ID=server-main yoi-runtime identity init --runtime-id <runtime-id>
RUNTIME_ID=runtime-main yoi-runtime identity show
RUNTIME_BASE_URL=http://127.0.0.1:38800
``` ```
`SERVER_ID` is the issuer id in Server-signed tokens. `RUNTIME_ID` is the token audience and must match the Runtime identity. 2. Workspace owner が Settings → Runtimes から Runtime public bundle と endpoint を登録する。
3. Server が Workspace issuer bundle と challenge を発行する。
4. operator が bundle を Runtime に追加する。
## 1. Create and show the Server identity ```sh
yoi-runtime trust-workspace add --bundle <workspace-issuer-bundle.json>
From the Workspace Server host: yoi-runtime trust-workspace show --workspace-id <workspace-id>
```bash
yoi-server identity init --server-id server-main
``` ```
Show the public identity and copy the `public_key` value: 5. Runtime が challenge proof を生成し、Workspace owner が Server に submit する。
6. Server が verified binding を commit した後、通常の Workspace-signed request が利用可能になる。
```bash 同じ Runtime identity は異なる Workspace から独立して信頼できる。trust record、replay protection、binding、失効はすべて Workspace scope で評価する。
yoi-server identity show --json
```
The Server private identity is stored in the Yoi data directory under the Server data root, currently: ## Runtime auth file
```text `runtime-auth.toml` は Runtime identity と Workspace issuer records のみを authority とする。
<data_dir>/server/identity.toml 旧 Server trust entry は読み飛ばされ、以後の identity / `trust-workspace` 更新時に書き戻されない。旧 entry を残しても認証には使用されない。
```
On Unix this file is written with `0600` permissions. Do not copy the private key to Runtime or commit it to the repository. `trust-workspace` の file store は次を fail closed で検証する。
## 2. Create and show the Runtime identity - 最大 8 MiB
- 最大 4,096 records
- exact Workspace / Runtime identity
- key id/generation と public key fingerprint
- normalized Backend URL
- replace 時の expected current generation
- list は `offset` / `limit` 必須で、1 page 最大 100 records
From the Runtime host, using the same Runtime storage flags that the Runtime server process will use: ## Local token
```bash `--local-token` は明示的な local Runtime 呼び出し専用であり、Remote Workspace binding の代替ではない。Workspace issuer auth が有効な Remote Runtime request は Workspace capability token を使う。
yoi-runtime identity init --runtime-id runtime-main
```
Show the public identity and copy the `public_key` value: ## Rotation と失効
```bash Workspace signing key または Runtime key の変更は、現在 binding を置き換える明示的な provisioning 操作として行う。古い generation、古い Runtime key、revoked binding、失効済み token、replayed JTI は即時拒否する。
yoi-runtime identity show --json
```
By default, Runtime auth state is stored at: Server の Runtime cache は現在の persisted binding 全体と照合する。endpoint、Runtime public key/fingerprint、binding revision、Workspace key generation の変更を検知した場合、stale client を利用しない。
```text ## 運用確認
<data_dir>/runtime/auth.toml
```
If the Runtime process is launched with `--fs-root` or `--fs-runtime-dir`, pass the same flags to every `identity` and `trust-server` command. Otherwise the setup command may write an auth file that the server process never reads. Remote Runtime を有効化した後は次を確認する。
Example with explicit Runtime storage: 1. `yoi-runtime trust-workspace show --workspace-id <workspace-id>` が期待する bundle を表示する。
2. Workspace Settings の Runtime binding が `verified` で、現在の key id/generation と verification evidence を表示する。
3. Runtime ping、Worker list/create、`worker.protocol` subscription が Workspace-signed token で成功する。
4. wrong Workspace、wrong Runtime、wrong target/body、expired token、revoked/replaced binding、replayed JTI が拒否される。
5. Runtime → Server source proof が configured Backend public URL audience と一致し、spoofed headers だけでは認証されない。
```bash Server / Runtime の再起動は live reload ではない authority 変更を反映するときだけ、通常の運用権限と migration gate に従って行う。実行中プロセスを開発 Worker が無断で停止してはならない。
yoi-runtime identity init \
--runtime-id runtime-main \
--fs-root /var/lib/yoi-runtime
yoi-runtime identity show \
--json \
--fs-root /var/lib/yoi-runtime
```
## 3. Register the Server public key on Runtime
On the Runtime host, register the Server public key copied from `yoi-server identity show --json`:
```bash
yoi-runtime trust-server add \
--server-id server-main \
--public-key '<SERVER_PUBLIC_KEY>'
```
With explicit Runtime storage, keep using the same storage flags:
```bash
yoi-runtime trust-server add \
--server-id server-main \
--public-key '<SERVER_PUBLIC_KEY>' \
--fs-root /var/lib/yoi-runtime
```
Verify:
```bash
yoi-runtime trust-server list --json
```
## 4. Register the Runtime public key and endpoint on Server
On the Workspace Server host, register the Runtime public key copied from `yoi-runtime identity show --json`:
```bash
yoi-server trust-runtime add \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main \
--base-url http://127.0.0.1:38800 \
--public-key '<RUNTIME_PUBLIC_KEY>' \
--display-name 'Runtime main'
```
This writes a Workspace-scoped Runtime binding and trust fingerprint to the Server DB. During `yoi-server serve`, active bindings are loaded as remote Runtime sources and receive signed capability tokens. Repository-external Runtime files are not registration or trust authority.
Verify:
```bash
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
```
## 5. Start Runtime and Workspace Server
Start Runtime with the same storage flags used during Runtime identity/trust setup:
```bash
yoi-runtime \
--bind 127.0.0.1:38800
```
For repository builds, the equivalent cargo command is:
```bash
cargo run -p worker-runtime \
--bin yoi-runtime \
-- --bind 127.0.0.1:38800
```
Start Workspace Server:
```bash
yoi-server serve --listen 127.0.0.1:8787
```
For repository builds:
```bash
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
```
An empty Server DB is valid. Open the Web UI, create or authenticate the Account, and register the first Workspace through the normal Workspace creation flow. Server startup does not create a Workspace from its current working directory or repository-local configuration.
## Smoke checks
Check both trust stores:
```bash
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
yoi-runtime trust-server list --json
```
Check that Workspace Server can see Runtime workers through the authenticated path. From the CLI:
```bash
yoi workers \
--backend http://127.0.0.1:8787 \
--runtime-id runtime-main
```
In Web, open the Workspace UI through Workspace Server and verify that Runtime worker listing, worker creation, and Console protocol input work. The protocol WebSocket uses the same Server-signed Runtime auth path as REST control calls.
## Rotation and replacement
Identity and trust records are intentionally not overwritten by default.
Rotate Server identity:
```bash
yoi-server identity init --server-id server-main --replace
```
After Server identity rotation, every Runtime that trusts that Server must be updated with the new Server public key:
```bash
yoi-runtime trust-server add \
--server-id server-main \
--public-key '<NEW_SERVER_PUBLIC_KEY>' \
--replace
```
Rotate Runtime identity:
```bash
yoi-runtime identity init --runtime-id runtime-main --replace
```
After Runtime identity rotation, Server must be updated with the new Runtime public key:
```bash
yoi-server trust-runtime add \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main \
--base-url http://127.0.0.1:38800 \
--public-key '<NEW_RUNTIME_PUBLIC_KEY>' \
--replace
```
## Revocation
Revoke a trusted Runtime on Server:
```bash
yoi-server trust-runtime revoke \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main
```
Remove a trusted Server from Runtime:
```bash
yoi-runtime trust-server revoke --server-id server-main
```
## Troubleshooting
### `trusted runtimes are registered but server identity is not initialized`
The Server DB contains trusted Runtime records, but the Server signing identity file does not exist. Run:
```bash
yoi-server identity init --server-id server-main
```
If the identity was created in another environment, ensure the Server process is using the same Yoi data directory.
### Runtime accepts unauthenticated requests
Runtime only enables signed capability-token auth when both a Runtime identity and at least one trusted Server are present in its auth file. Check:
```bash
yoi-runtime identity show --json
yoi-runtime trust-server list --json
```
Also confirm the Runtime process was started with the same `--fs-root` / `--fs-runtime-dir` used for setup.
### Wrong audience or unauthorized Runtime response
Confirm the `--runtime-id` registered on Server exactly matches the Runtime identity id:
```bash
yoi-runtime identity show --json
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
```
`RUNTIME_ID` is the token audience; mismatches are rejected by Runtime.
### Duplicate registration fails
This is expected. Use `--replace` only when intentionally rotating or updating trust material.
-301
View File
@@ -1,301 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Starts production Server/Runtime binaries against disposable state and ports.
# This script must never read or write the caller's Yoi data/config directories.
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
server_bin=${YOI_SMOKE_SERVER_BIN:-"$repo_root/target/debug/yoi-server"}
runtime_bin=${YOI_SMOKE_RUNTIME_BIN:-"$repo_root/target/debug/yoi-runtime"}
server_port=${YOI_SMOKE_SERVER_PORT:-48787}
runtime_port=${YOI_SMOKE_RUNTIME_PORT:-48800}
keep=${YOI_SMOKE_KEEP:-0}
fail() {
printf 'isolated-startup-smoke: %s\n' "$*" >&2
exit 1
}
for command in curl git node ss; do
command -v "$command" >/dev/null || fail "required command is unavailable: $command"
done
[[ -x "$server_bin" ]] || fail "Server binary is not executable: $server_bin"
[[ -x "$runtime_bin" ]] || fail "Runtime binary is not executable: $runtime_bin"
[[ "$server_port" =~ ^[0-9]+$ ]] || fail "invalid Server port: $server_port"
[[ "$runtime_port" =~ ^[0-9]+$ ]] || fail "invalid Runtime port: $runtime_port"
[[ "$server_port" != "$runtime_port" ]] || fail "Server and Runtime ports must differ"
port_is_listening() {
local port=$1
ss -H -ltn "sport = :$port" | grep -q .
}
port_is_listening "$server_port" && fail "Server smoke port is already in use: $server_port"
port_is_listening "$runtime_port" && fail "Runtime smoke port is already in use: $runtime_port"
root=$(mktemp -d "${TMPDIR:-/tmp}/yoi-isolated-startup-smoke.XXXXXX")
server_pid=
runtime_pid=
stop_pid() {
local pid=${1:-}
[[ -n "$pid" ]] || return 0
kill -TERM "$pid" 2>/dev/null || true
for _ in $(seq 1 50); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.1
done
if kill -0 "$pid" 2>/dev/null; then
kill -KILL "$pid" 2>/dev/null || true
fi
wait "$pid" 2>/dev/null || true
}
cleanup() {
local status=$?
trap - EXIT INT TERM
stop_pid "$runtime_pid"
stop_pid "$server_pid"
if [[ "$keep" == 1 ]]; then
printf 'isolated-startup-smoke: kept artifacts at %s\n' "$root" >&2
elif [[ $status -eq 0 ]]; then
rm -rf "$root"
else
printf 'isolated-startup-smoke: failed; artifacts kept at %s\n' "$root" >&2
fi
exit "$status"
}
trap cleanup EXIT INT TERM
mkdir -p "$root/home" "$root/data" "$root/config" "$root/repository" "$root/logs"
export HOME="$root/home"
export XDG_DATA_HOME="$root/data"
export XDG_CONFIG_HOME="$root/config"
unset YOI_DATA_DIR YOI_CONFIG_HOME
# Fail closed if isolation variables no longer point below the disposable root.
case "$HOME:$XDG_DATA_HOME:$XDG_CONFIG_HOME" in
"$root"/*:"$root"/*:"$root"/*) ;;
*) fail "HOME/XDG isolation guard failed" ;;
esac
server_url="http://127.0.0.1:$server_port"
runtime_url="http://127.0.0.1:$runtime_port"
server_id=isolated-smoke-server
runtime_id=isolated-smoke-runtime
git -C "$root/repository" init -q
git -C "$root/repository" config user.email smoke@example.invalid
git -C "$root/repository" config user.name 'Yoi isolated smoke'
printf '# isolated smoke\n' >"$root/repository/README.md"
git -C "$root/repository" add README.md
git -C "$root/repository" commit -qm 'test: initialize isolated smoke repository'
"$server_bin" identity init --server-id "$server_id" >"$root/logs/server-identity-init.log" 2>&1
"$runtime_bin" identity init --runtime-id "$runtime_id" >"$root/logs/runtime-identity-init.log" 2>&1
server_identity=$("$server_bin" identity show --json)
runtime_identity=$("$runtime_bin" identity show --json)
server_key=$(printf '%s' "$server_identity" | node -e 'const fs=require("fs"); process.stdout.write(JSON.parse(fs.readFileSync(0,"utf8")).public_key)')
runtime_key=$(printf '%s' "$runtime_identity" | node -e 'const fs=require("fs"); process.stdout.write(JSON.parse(fs.readFileSync(0,"utf8")).public_key)')
"$server_bin" trust-runtime add \
--runtime-id "$runtime_id" \
--public-key "$runtime_key" \
--base-url "$runtime_url" >"$root/logs/server-trust-runtime.log" 2>&1
"$runtime_bin" trust-server add \
--server-id "$server_id" \
--public-key "$server_key" >"$root/logs/runtime-trust-server.log" 2>&1
"$server_bin" init --workspace "$root/repository" >"$root/logs/server-init.log" 2>&1
workspace_id=$(sed -n 's/^workspace_id = "\([^"]*\)"/\1/p' "$root/repository/.yoi/workspace.toml")
[[ -n "$workspace_id" ]] || fail "workspace init did not write workspace_id"
# Runtime Git materialization requires a clean source repository. Commit both
# local bootstrap markers inside this disposable repository.
git -C "$root/repository" add .yoi/workspace.toml .yoi/workspace-backend.local.toml
git -C "$root/repository" commit -qm 'test: record isolated Yoi workspace markers'
runtime_store="$XDG_DATA_HOME/yoi/runtime"
mkdir -p "$runtime_store/workers"
cat >"$runtime_store/runtime.json" <<'JSON'
{
"schema_version": 1,
"display_name": "isolated startup smoke",
"backend": "fs_store",
"status": "running",
"next_worker_sequence": 1,
"next_diagnostic_id": 1,
"config_bundles": {},
"workspace_owners": {},
"diagnostics": []
}
JSON
start_server() {
: >"$root/logs/server.log"
"$server_bin" serve --listen "127.0.0.1:$server_port" >"$root/logs/server.log" 2>&1 &
server_pid=$!
}
start_runtime() {
: >"$root/logs/runtime.log"
"$runtime_bin" --bind "127.0.0.1:$runtime_port" >"$root/logs/runtime.log" 2>&1 &
runtime_pid=$!
}
wait_for_listener() {
local pid=$1
local port=$2
local name=$3
for _ in $(seq 1 150); do
kill -0 "$pid" 2>/dev/null || fail "$name exited before listening; inspect $root/logs"
port_is_listening "$port" && return 0
sleep 0.1
done
fail "$name did not listen on port $port within 15 seconds"
}
runtime_projection() {
curl --fail --silent --show-error \
"$server_url/api/w/$workspace_id/runtimes"
}
projection_is_ready() {
node -e '
const fs = require("fs");
const runtimeId = process.argv[1];
const body = JSON.parse(fs.readFileSync(0, "utf8"));
const runtime = body.items.find((item) => item.runtime_id === runtimeId);
if (!runtime || runtime.status !== "running") process.exit(1);
if (!runtime.capabilities?.can_list_workers) process.exit(1);
if ((runtime.diagnostics ?? []).length !== 0) process.exit(1);
if ((body.diagnostics ?? []).length !== 0) process.exit(1);
' "$runtime_id"
}
wait_for_projection_state() {
local expected=$1
local body=
for _ in $(seq 1 150); do
kill -0 "$server_pid" 2>/dev/null || fail "Server exited during readiness check"
body=$(runtime_projection 2>/dev/null || true)
if [[ -n "$body" ]]; then
if printf '%s' "$body" | projection_is_ready 2>/dev/null; then
[[ "$expected" == ready ]] && return 0
else
[[ "$expected" == not-ready ]] && return 0
fi
fi
sleep 0.1
done
printf '%s\n' "$body" >"$root/logs/last-runtime-projection.json"
fail "Runtime projection did not become $expected within 15 seconds"
}
assert_clean_logs() {
if grep -Eiq 'panicked at|thread .* panicked|UNIQUE constraint failed|worker_execution_restore_failed' \
"$root/logs/server.log" "$root/logs/runtime.log"; then
fail "panic, migration collision, or restore failure found in startup logs"
fi
}
start_server
wait_for_listener "$server_pid" "$server_port" Server
# Negative control: a listening Server is not readiness. The configured remote
# Runtime must be rejected while it is absent.
wait_for_projection_state not-ready
start_runtime
wait_for_listener "$runtime_pid" "$runtime_port" Runtime
wait_for_projection_state ready
assert_clean_logs
# Listener/catalog readiness is insufficient. Materialize a real Workdir and
# require the normal Server -> Runtime Worker spawn path to create a persisted
# Worker with an execution handle. This catches adapter panics that startup
# alone cannot observe.
repositories=$(curl --fail --silent --show-error \
"$server_url/api/w/$workspace_id/repositories")
repository_id=$(printf '%s' "$repositories" | node -e '
const fs = require("fs");
const body = JSON.parse(fs.readFileSync(0, "utf8"));
if (body.items.length !== 1) process.exit(1);
process.stdout.write(body.items[0].id);
')
workdir_response=$(curl --fail --silent --show-error \
--request POST \
--header 'content-type: application/json' \
--data "{\"runtime_id\":\"$runtime_id\",\"repository_id\":\"$repository_id\"}" \
"$server_url/api/w/$workspace_id/runtimes/$runtime_id/working-directories") || \
fail "isolated Workdir materialization failed"
working_directory_id=$(printf '%s' "$workdir_response" | node -e '
const fs = require("fs");
const body = JSON.parse(fs.readFileSync(0, "utf8"));
if (body.item?.status !== "active" || body.item?.cleanliness !== "clean") process.exit(1);
process.stdout.write(body.item.working_directory_id);
')
cat >"$root/worker-create.json" <<JSON
{
"runtime_id": "$runtime_id",
"display_name": "isolated restore smoke",
"profile": "builtin:companion",
"initial_submit": [],
"working_directory": {
"working_directory_id": "$working_directory_id"
}
}
JSON
worker_response=$(curl --fail --silent --show-error \
--request POST \
--header 'content-type: application/json' \
--data @"$root/worker-create.json" \
"$server_url/api/w/$workspace_id/workers") || \
fail "isolated Worker spawn failed; listener readiness is not sufficient"
worker_id=$(printf '%s' "$worker_response" | node -e '
const fs = require("fs");
const body = JSON.parse(fs.readFileSync(0, "utf8"));
if (body.runtime_id !== process.argv[1] || !body.worker_id) process.exit(1);
process.stdout.write(String(body.worker_id));
' "$runtime_id")
node -e '
const fs = require("fs");
const record = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const expectedBase = process.argv[2];
const profileUrl = record.request?.profile_source?.location?.url;
const workspaceUrl = record.request?.workspace_api?.base_url;
if (!profileUrl?.startsWith(`${expectedBase}/`)) {
console.error(`profile callback escaped isolated Server: ${profileUrl}`);
process.exit(1);
}
if (workspaceUrl !== expectedBase) {
console.error(`Workspace API escaped isolated Server: ${workspaceUrl}`);
process.exit(1);
}
' "$runtime_store/workers/$worker_id/worker.json" "$server_url" || \
fail "persisted Worker callback URLs are not isolated"
assert_clean_logs
# Exercise persistence reopen with a real persisted Worker and require the
# Server projection to recover. The Worker record must remain addressable after
# Runtime restart; restore failures and adapter panics are rejected by log scan.
stop_pid "$runtime_pid"
runtime_pid=
wait_for_projection_state not-ready
start_runtime
wait_for_listener "$runtime_pid" "$runtime_port" Runtime
wait_for_projection_state ready
curl --fail --silent --show-error \
"$server_url/api/w/$workspace_id/runtimes/$runtime_id/workers/$worker_id" \
>"$root/logs/restored-worker.json" || fail "persisted Worker is unavailable after Runtime restart"
assert_clean_logs
# Prove that this run used only disposable state paths.
grep -Fq "$root/data/yoi/server/server.db" "$root/logs/server.log" || \
fail "Server log does not identify the isolated database"
if grep -Fq '/home/hare/.local/share/yoi' "$root/logs/server.log" "$root/logs/runtime.log"; then
fail "startup logs reference a non-isolated Yoi data path"
fi
printf 'isolated-startup-smoke: PASS (workspace=%s, server=%s, runtime=%s)\n' \
"$workspace_id" "$server_url" "$runtime_url"
@@ -165,6 +165,35 @@ export type WorkspaceMetadataMutationResponse = {
diagnostics: Array<Diagnostic>; diagnostics: Array<Diagnostic>;
}; };
export type WorkspaceSigningIdentityState = "pending_provisioning" | "active";
export type WorkspaceSigningIdentityPublic = {
workspace_id: string;
key_id: string;
algorithm: string;
public_key?: string;
public_key_fingerprint?: string;
revision: number;
state: WorkspaceSigningIdentityState;
created_at: string;
provisioned_at?: string;
};
export type WorkspacePublicIdentityBundle = {
workspace_id: string;
backend_url: string;
key_id: string;
algorithm: string;
public_key: string;
public_key_fingerprint: string;
revision: number;
};
export type WorkspaceSigningIdentityResponse = {
identity: WorkspaceSigningIdentityPublic;
public_bundle?: WorkspacePublicIdentityBundle;
};
export type ProfileSettingsResponse = { export type ProfileSettingsResponse = {
workspace_id: string; workspace_id: string;
registry_revision: string; registry_revision: string;
@@ -315,12 +344,51 @@ export type RuntimeSummary = {
diagnostics: Array<Diagnostic>; diagnostics: Array<Diagnostic>;
}; };
export type WorkspaceRuntimeBindingState =
| "configured"
| "verified"
| "revoked";
export type RuntimeConnectionDisplayState =
| "configured"
| "verified"
| "unavailable"
| "revoked";
export type RuntimeVerificationOutcome =
| "verified"
| "challenge_issued"
| "verification_failed"
| "connectivity_failed";
export type RuntimeVerificationEvidenceSummary = {
verified_at: string | null;
last_checked_at: string;
last_outcome: RuntimeVerificationOutcome;
binding_revision: number;
workspace_key_id: string;
workspace_identity_revision: number;
workspace_trust_generation: number;
runtime_public_key_fingerprint: string;
runtime_identity_revision: number;
};
export type WorkspaceRuntimeBindingSummary = {
state: WorkspaceRuntimeBindingState;
connection_state: RuntimeConnectionDisplayState;
revision: number;
workspace_key_id?: string | null;
workspace_key_generation?: number | null;
verification?: RuntimeVerificationEvidenceSummary | null;
};
export type RuntimeManagementSummary = { export type RuntimeManagementSummary = {
built_in: boolean; built_in: boolean;
config_managed: boolean; config_managed: boolean;
removable: boolean; removable: boolean;
endpoint_configured: boolean; endpoint_configured: boolean;
token_ref_configured: boolean; token_ref_configured: boolean;
binding?: WorkspaceRuntimeBindingSummary | null;
}; };
export type WorkspaceRuntimeResource = { export type WorkspaceRuntimeResource = {
@@ -373,11 +441,6 @@ export type WorkspaceRuntimeDetail = {
export type RuntimeTrustKeyRevealResponse = { public_key: string }; export type RuntimeTrustKeyRevealResponse = { public_key: string };
export type PutRuntimeTrustKeyRequest = {
public_key: string;
expected_revision: number | null;
};
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number }; export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use"; export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
@@ -389,6 +452,18 @@ export type RuntimeTrustConflictResponse = {
current_fingerprint?: string | null; current_fingerprint?: string | null;
}; };
export type RuntimePublicIdentityBundle = {
identity_id: string;
public_key: string;
};
export type CreateRemoteRuntimeRequest = {
public_bundle: RuntimePublicIdentityBundle;
display_name?: string | null;
endpoint: string;
expected_revision?: number | null;
};
export type RuntimeConnectionTestStatus = "compatible" | "failed"; export type RuntimeConnectionTestStatus = "compatible" | "failed";
export type RuntimeConnectionTestFailureKind = export type RuntimeConnectionTestFailureKind =
@@ -405,6 +480,9 @@ export type RuntimeConnectionTestFailureKind =
export type RuntimeConnectionTestResponse = { export type RuntimeConnectionTestResponse = {
workspace_id: string; workspace_id: string;
runtime_id: string; runtime_id: string;
binding_revision: number;
connection_state: RuntimeConnectionDisplayState;
verification: RuntimeVerificationEvidenceSummary | null;
checked_at: string; checked_at: string;
status: RuntimeConnectionTestStatus; status: RuntimeConnectionTestStatus;
failure_kind: RuntimeConnectionTestFailureKind | null; failure_kind: RuntimeConnectionTestFailureKind | null;
@@ -2,11 +2,15 @@ import type {
Diagnostic, Diagnostic,
RuntimeConnectionTestFailureKind, RuntimeConnectionTestFailureKind,
RuntimeConnectionTestResponse, RuntimeConnectionTestResponse,
RuntimeVerificationEvidenceSummary,
} from "$lib/generated/workspace-api"; } from "$lib/generated/workspace-api";
const RESPONSE_KEYS = [ const RESPONSE_KEYS = [
"workspace_id", "workspace_id",
"runtime_id", "runtime_id",
"binding_revision",
"connection_state",
"verification",
"checked_at", "checked_at",
"status", "status",
"failure_kind", "failure_kind",
@@ -103,9 +107,28 @@ export function parseRuntimeConnectionTestResponse(
) { ) {
return null; return null;
} }
const bindingRevision = value.binding_revision;
const connectionState = parseConnectionState(value.connection_state);
const verification = parseVerificationEvidence(value.verification);
if (
!isSafeRevision(bindingRevision) ||
connectionState === null ||
(value.verification !== null && verification === null) ||
(verification !== null &&
verification.binding_revision !== bindingRevision) ||
(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_revision: bindingRevision,
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,
@@ -115,6 +138,67 @@ export function parseRuntimeConnectionTestResponse(
}; };
} }
function parseConnectionState(
value: unknown,
): "configured" | "verified" | "unavailable" | "revoked" | null {
return value === "configured" || value === "verified" ||
value === "unavailable" || value === "revoked"
? value
: null;
}
function isSafeRevision(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function parseVerificationEvidence(
value: unknown,
): RuntimeVerificationEvidenceSummary | null {
if (value === null) return null;
const keys = [
"verified_at",
"last_checked_at",
"last_outcome",
"binding_revision",
"workspace_key_id",
"workspace_identity_revision",
"workspace_trust_generation",
"runtime_public_key_fingerprint",
"runtime_identity_revision",
] 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") ||
!isSafeRevision(value.binding_revision) ||
!isBoundedString(value.workspace_key_id, 128) ||
!isSafeRevision(value.workspace_identity_revision) ||
!isSafeRevision(value.workspace_trust_generation) ||
!isBoundedString(value.runtime_public_key_fingerprint, 128) ||
!isSafeRevision(value.runtime_identity_revision)
) {
return null;
}
return {
verified_at: value.verified_at,
last_checked_at: value.last_checked_at,
last_outcome: value.last_outcome,
binding_revision: value.binding_revision,
workspace_key_id: value.workspace_key_id,
workspace_identity_revision: value.workspace_identity_revision,
workspace_trust_generation: value.workspace_trust_generation,
runtime_public_key_fingerprint: value.runtime_public_key_fingerprint,
runtime_identity_revision: value.runtime_identity_revision,
};
}
export async function testRuntimeConnection( export async function testRuntimeConnection(
workspaceId: string, workspaceId: string,
runtimeId: string, runtimeId: string,
@@ -1,7 +1,8 @@
import type { import type {
CreateRemoteRuntimeRequest,
Diagnostic, Diagnostic,
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, RevokeRuntimeTrustKeyRequest,
RuntimeConnectionDisplayState,
RuntimeIdentityAuthority, RuntimeIdentityAuthority,
RuntimeManagementSummary, RuntimeManagementSummary,
RuntimeSourceKind, RuntimeSourceKind,
@@ -14,6 +15,9 @@ import type {
RuntimeTrustKeyRevealResponse, RuntimeTrustKeyRevealResponse,
RuntimeTrustKeyState, RuntimeTrustKeyState,
RuntimeTrustKeyStatus, RuntimeTrustKeyStatus,
RuntimeVerificationEvidenceSummary,
WorkspaceRuntimeBindingState,
WorkspaceRuntimeBindingSummary,
WorkspaceRuntimeDetail, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource, WorkspaceRuntimeResource,
} from "$lib/generated/workspace-api.ts"; } from "$lib/generated/workspace-api.ts";
@@ -67,6 +71,17 @@ const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
"stale_revision", "stale_revision",
"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 encoder = new TextEncoder(); const encoder = new TextEncoder();
type JsonObject = Record<string, unknown>; type JsonObject = Record<string, unknown>;
@@ -286,6 +301,145 @@ 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_revision",
"workspace_key_id",
"workspace_identity_revision",
"workspace_trust_generation",
"runtime_public_key_fingerprint",
"runtime_identity_revision",
],
[],
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_revision: safeRevision(
item.binding_revision,
`${path}.binding_revision`,
),
workspace_key_id: boundedString(
item.workspace_key_id,
`${path}.workspace_key_id`,
LIMITS.idBytes,
),
workspace_identity_revision: safeRevision(
item.workspace_identity_revision,
`${path}.workspace_identity_revision`,
),
workspace_trust_generation: safeRevision(
item.workspace_trust_generation,
`${path}.workspace_trust_generation`,
),
runtime_public_key_fingerprint: boundedString(
item.runtime_public_key_fingerprint,
`${path}.runtime_public_key_fingerprint`,
LIMITS.fingerprintBytes,
),
runtime_identity_revision: safeRevision(
item.runtime_identity_revision,
`${path}.runtime_identity_revision`,
),
};
}
function runtimeBinding(
value: unknown,
path: string,
): WorkspaceRuntimeBindingSummary {
const item = object(value, path);
exactKeys(
item,
["state", "connection_state", "revision"],
["workspace_key_id", "workspace_key_generation", "verification"],
path,
);
const workspaceKeyId = optionalNullableString(
item.workspace_key_id,
`${path}.workspace_key_id`,
LIMITS.idBytes,
);
const workspaceKeyGeneration = optionalNullableRevision(
item.workspace_key_generation,
`${path}.workspace_key_generation`,
);
const state = enumValue(item.state, `${path}.state`, BINDING_STATES);
if (
state !== "revoked" &&
(workspaceKeyId == null || workspaceKeyGeneration == null)
) {
return fail(path, "requires Workspace signing key identity metadata");
}
const connectionState = enumValue(
item.connection_state,
`${path}.connection_state`,
CONNECTION_STATES,
);
const revision = safeRevision(item.revision, `${path}.revision`);
const verification = item.verification === undefined
? undefined
: runtimeVerification(item.verification, `${path}.verification`);
if (
verification !== undefined && verification.binding_revision !== revision
) {
return fail(path, "verification must match the current binding revision");
}
if (
connectionState === "verified" &&
(verification === undefined ||
verification.verified_at === null ||
verification.last_outcome !== "verified")
) {
return fail(
path,
"verified Workspace identity binding requires verification evidence",
);
}
return {
state,
connection_state: connectionState,
revision,
...(workspaceKeyId === undefined
? {}
: { workspace_key_id: workspaceKeyId }),
...(workspaceKeyGeneration === undefined
? {}
: { workspace_key_generation: workspaceKeyGeneration }),
...(verification === undefined ? {} : { verification }),
};
}
function runtimeManagement( function runtimeManagement(
value: unknown, value: unknown,
path: string, path: string,
@@ -300,9 +454,12 @@ function runtimeManagement(
"endpoint_configured", "endpoint_configured",
"token_ref_configured", "token_ref_configured",
], ],
[], ["binding"],
path, path,
); );
const binding = item.binding == null
? undefined
: runtimeBinding(item.binding, `${path}.binding`);
return { return {
built_in: boolean(item.built_in, `${path}.built_in`), built_in: boolean(item.built_in, `${path}.built_in`),
config_managed: boolean(item.config_managed, `${path}.config_managed`), config_managed: boolean(item.config_managed, `${path}.config_managed`),
@@ -315,6 +472,7 @@ function runtimeManagement(
item.token_ref_configured, item.token_ref_configured,
`${path}.token_ref_configured`, `${path}.token_ref_configured`,
), ),
...(binding === undefined ? {} : { binding }),
}; };
} }
@@ -648,6 +806,26 @@ function requestErrorFrom(
): RuntimeTrustRequestError { ): RuntimeTrustRequestError {
try { try {
const response = object(value, "Runtime trust error"); const response = object(value, "Runtime trust error");
if ("details" in response) {
exactKeys(
response,
["error", "details"],
[],
"Runtime trust error",
);
boundedString(
response.error,
"Runtime trust error.error",
LIMITS.idBytes,
);
return new RuntimeTrustRequestError(
boundedString(
response.details,
"Runtime trust error.details",
LIMITS.conflictMessageBytes,
),
);
}
exactKeys( exactKeys(
response, response,
["error", "message", "diagnostics"], ["error", "message", "diagnostics"],
@@ -713,6 +891,30 @@ async function finishMutation(
return detail; return detail;
} }
export async function createRemoteRuntime(
workspaceId: string,
request: CreateRemoteRuntimeRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeResource> {
const response = await fetchImpl(
workspaceApiPath(workspaceId, "/runtimes"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
);
const payload = await readBoundedJson(response);
if (!response.ok) throw requestErrorFrom(payload, response.status);
const runtime = runtimeResource(payload, "Runtime create response");
if (runtime.runtime_id !== request.public_bundle.identity_id) {
throw new RuntimeTrustRequestError(
"Runtime create response did not match the submitted public bundle",
);
}
return runtime;
}
export async function revealRuntimeTrustKey( export async function revealRuntimeTrustKey(
workspaceId: string, workspaceId: string,
runtimeId: string, runtimeId: string,
@@ -763,29 +965,6 @@ export async function previewRuntimePublicKeyFingerprint(
return `sha256:${hex}`; return `sha256:${hex}`;
} }
export async function putRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
request: PutRuntimeTrustKeyRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeDetail> {
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
),
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
public_key: request.public_key,
expected_revision: revisionForJson(request.expected_revision),
}),
},
);
return await finishMutation(response, workspaceId, runtimeId);
}
export async function revokeRuntimeTrustKey( export async function revokeRuntimeTrustKey(
workspaceId: string, workspaceId: string,
runtimeId: string, runtimeId: string,
@@ -8,6 +8,10 @@ import type {
WorkspaceProfileSourceProvenance, WorkspaceProfileSourceProvenance,
WorkspaceProfileSourceSummary, WorkspaceProfileSourceSummary,
WorkspaceProfileSummary, WorkspaceProfileSummary,
WorkspacePublicIdentityBundle,
WorkspaceSigningIdentityPublic,
WorkspaceSigningIdentityResponse,
WorkspaceSigningIdentityState,
} from "$lib/generated/workspace-api"; } from "$lib/generated/workspace-api";
export class ProfileApiError extends Error { export class ProfileApiError extends Error {
@@ -51,6 +55,18 @@ function stringValue(value: unknown, context: string): string {
return value; return value;
} }
function boundedStringValue(
value: unknown,
context: string,
maxBytes: number,
): string {
const text = stringValue(value, context);
if (new TextEncoder().encode(text).byteLength > maxBytes) {
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
}
return text;
}
function booleanValue(value: unknown, context: string): boolean { function booleanValue(value: unknown, context: string): boolean {
if (typeof value !== "boolean") { if (typeof value !== "boolean") {
throw new ProfileApiError(`${context} returned an invalid response.`, 502); throw new ProfileApiError(`${context} returned an invalid response.`, 502);
@@ -66,6 +82,15 @@ function optionalString(
return stringValue(value, context); return stringValue(value, context);
} }
function optionalBoundedString(
value: unknown,
context: string,
maxBytes: number,
): string | null | undefined {
if (value === undefined || value === null) return value;
return boundedStringValue(value, context, maxBytes);
}
function optionalRevision( function optionalRevision(
value: unknown, value: unknown,
context: string, context: string,
@@ -286,6 +311,197 @@ export function parseProfileSettingsResponse(
}; };
} }
export function parseWorkspaceSigningIdentityResponse(
value: unknown,
): WorkspaceSigningIdentityResponse {
const item = record(value, "Workspace signing identity");
exactKeys(
item,
["identity"],
["public_bundle"],
"Workspace signing identity",
);
const identityItem = record(item.identity, "Workspace signing identity");
exactKeys(
identityItem,
["workspace_id", "key_id", "algorithm", "revision", "state", "created_at"],
["public_key", "public_key_fingerprint", "provisioned_at"],
"Workspace signing identity",
);
const state = boundedStringValue(
identityItem.state,
"Workspace signing identity",
32,
);
if (state !== "pending_provisioning" && state !== "active") {
throw new ProfileApiError(
"Workspace signing identity returned an invalid response.",
502,
);
}
const revision = optionalRevision(
identityItem.revision,
"Workspace signing identity",
);
if (revision === undefined || revision === null || revision < 1) {
throw new ProfileApiError(
"Workspace signing identity returned an invalid response.",
502,
);
}
const publicKey = optionalBoundedString(
identityItem.public_key,
"Workspace signing identity",
256,
);
const fingerprint = optionalBoundedString(
identityItem.public_key_fingerprint,
"Workspace signing identity",
128,
);
const provisionedAt = optionalBoundedString(
identityItem.provisioned_at,
"Workspace signing identity",
128,
);
const identity: WorkspaceSigningIdentityPublic = {
workspace_id: boundedStringValue(
identityItem.workspace_id,
"Workspace signing identity",
128,
),
key_id: boundedStringValue(
identityItem.key_id,
"Workspace signing identity",
128,
),
algorithm: boundedStringValue(
identityItem.algorithm,
"Workspace signing identity",
32,
),
...(publicKey === undefined || publicKey === null
? {}
: { public_key: publicKey }),
...(fingerprint === undefined || fingerprint === null
? {}
: { public_key_fingerprint: fingerprint }),
revision,
state: state as WorkspaceSigningIdentityState,
created_at: boundedStringValue(
identityItem.created_at,
"Workspace signing identity",
128,
),
...(provisionedAt === undefined || provisionedAt === null
? {}
: { provisioned_at: provisionedAt }),
};
let publicBundle: WorkspacePublicIdentityBundle | undefined;
if (item.public_bundle !== undefined) {
const bundle = record(
item.public_bundle,
"Workspace public identity bundle",
);
exactKeys(
bundle,
[
"workspace_id",
"backend_url",
"key_id",
"algorithm",
"public_key",
"public_key_fingerprint",
"revision",
],
[],
"Workspace public identity bundle",
);
const bundleRevision = optionalRevision(
bundle.revision,
"Workspace public identity bundle",
);
if (
bundleRevision === undefined || bundleRevision === null ||
bundleRevision < 1
) {
throw new ProfileApiError(
"Workspace public identity bundle returned an invalid response.",
502,
);
}
publicBundle = {
workspace_id: boundedStringValue(
bundle.workspace_id,
"Workspace public identity bundle",
128,
),
backend_url: boundedStringValue(
bundle.backend_url,
"Workspace public identity bundle",
2048,
),
key_id: boundedStringValue(
bundle.key_id,
"Workspace public identity bundle",
128,
),
algorithm: boundedStringValue(
bundle.algorithm,
"Workspace public identity bundle",
32,
),
public_key: boundedStringValue(
bundle.public_key,
"Workspace public identity bundle",
256,
),
public_key_fingerprint: boundedStringValue(
bundle.public_key_fingerprint,
"Workspace public identity bundle",
128,
),
revision: bundleRevision,
};
}
if (
publicBundle !== undefined &&
(
publicBundle.workspace_id !== identity.workspace_id ||
publicBundle.key_id !== identity.key_id ||
publicBundle.algorithm !== identity.algorithm ||
publicBundle.public_key !== identity.public_key ||
publicBundle.public_key_fingerprint !== identity.public_key_fingerprint ||
publicBundle.revision !== identity.revision
)
) {
throw new ProfileApiError(
"Workspace public identity bundle does not match identity metadata.",
502,
);
}
if (
(state === "active" &&
(publicBundle === undefined || identity.public_key === undefined ||
identity.public_key_fingerprint === undefined ||
identity.provisioned_at === undefined)) ||
(state === "pending_provisioning" &&
(publicBundle !== undefined || identity.public_key !== undefined ||
identity.public_key_fingerprint !== undefined ||
identity.provisioned_at !== undefined))
) {
throw new ProfileApiError(
"Workspace signing identity returned an invalid response.",
502,
);
}
return {
identity,
...(publicBundle === undefined ? {} : { public_bundle: publicBundle }),
};
}
async function parseResponse<T>( async function parseResponse<T>(
response: Response, response: Response,
parser: (value: unknown) => T, parser: (value: unknown) => T,
@@ -325,6 +541,33 @@ export async function updateWorkspaceMetadata(
); );
} }
export async function fetchWorkspaceSigningIdentity(
workspaceId: string,
): Promise<WorkspaceSigningIdentityResponse> {
return await parseResponse(
await fetch(
`/api/w/${
encodeURIComponent(workspaceId)
}/settings/workspace/signing-identity`,
),
parseWorkspaceSigningIdentityResponse,
);
}
export async function provisionWorkspaceSigningIdentity(
workspaceId: string,
): Promise<WorkspaceSigningIdentityResponse> {
return await parseResponse(
await fetch(
`/api/w/${
encodeURIComponent(workspaceId)
}/settings/workspace/signing-identity/provision`,
{ method: "POST" },
),
parseWorkspaceSigningIdentityResponse,
);
}
export async function fetchProfileSettings( export async function fetchProfileSettings(
workspaceId: string, workspaceId: string,
): Promise<ProfileSettingsResponse> { ): Promise<ProfileSettingsResponse> {
@@ -2,20 +2,31 @@
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import type { import type {
RuntimeConnectionTestResponse, RuntimeConnectionTestResponse,
RuntimePublicIdentityBundle,
WorkspaceRuntimeResource, WorkspaceRuntimeResource,
} from '$lib/generated/workspace-api'; } from '$lib/generated/workspace-api';
import {
createRemoteRuntime,
previewRuntimePublicKeyFingerprint,
RuntimeTrustRequestError,
} from '$lib/workspace/api/runtime-management';
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection'; import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
import { workspaceApiPath } from '$lib/workspace/api/http';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
const runtimeBundlePlaceholder =
'{"identity_id":"team-runtime","public_key":"yoi-ed25519-pub:v1:..."}';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let runtimeId = $state(''); let runtimePublicBundle = $state('');
let displayName = $state(''); let displayName = $state('');
let endpoint = $state(''); let endpoint = $state('');
let runtimeFingerprint = $state<string | null>(null);
let fingerprintConfirmation = $state('');
let showAddRuntime = $state(false); let showAddRuntime = $state(false);
let busyRuntimeId = $state<string | null>(null); let busyRuntimeId = $state<string | null>(null);
let requestError = $state<string | null>(null); let requestError = $state<string | null>(null);
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({}); let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
let connectionTestGeneration = 0;
function runtimePlatform(runtime: WorkspaceRuntimeResource): string { function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown'; return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
@@ -23,7 +34,9 @@
function connectionTestSummary(result: RuntimeConnectionTestResponse): string { function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
if (result.status === 'compatible') { if (result.status === 'compatible') {
return `Compatible · protocol v${result.actual_protocol_version}`; return result.connection_state === 'verified'
? `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';
@@ -46,11 +59,57 @@
return 'Observed'; return 'Observed';
} }
async function responseError(response: Response): Promise<string> { function parseRuntimePublicBundle(value: string): RuntimePublicIdentityBundle {
const payload = await response.json().catch(() => null) as let parsed: unknown;
| { message?: string; error?: string } try {
| null; parsed = JSON.parse(value);
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`; } catch {
throw new Error('Runtime public bundle must be valid JSON');
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Runtime public bundle must be a JSON object');
}
const item = parsed as Record<string, unknown>;
if (
Object.keys(item).length !== 2 ||
typeof item.identity_id !== 'string' ||
item.identity_id.length === 0 ||
typeof item.public_key !== 'string' ||
item.public_key.length === 0
) {
throw new Error('Runtime public bundle must contain only identity_id and public_key');
}
return { identity_id: item.identity_id, public_key: item.public_key };
}
function workspacePublicBundle(): string {
return data.signingIdentity?.public_bundle
? JSON.stringify(data.signingIdentity.public_bundle, null, 2)
: '';
}
async function copyWorkspaceBundle(): Promise<void> {
requestError = null;
try {
await navigator.clipboard.writeText(workspacePublicBundle());
} catch {
requestError = 'Workspace public bundle could not be copied';
}
}
async function previewRuntimeFingerprint(): Promise<void> {
requestError = null;
runtimeFingerprint = null;
fingerprintConfirmation = '';
busyRuntimeId = 'preview';
try {
const bundle = parseRuntimePublicBundle(runtimePublicBundle);
runtimeFingerprint = await previewRuntimePublicKeyFingerprint(bundle.public_key);
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
} }
async function addRuntime(event: SubmitEvent): Promise<void> { async function addRuntime(event: SubmitEvent): Promise<void> {
@@ -58,40 +117,72 @@
requestError = null; requestError = null;
busyRuntimeId = 'create'; busyRuntimeId = 'create';
try { try {
const response = await fetch(workspaceApiPath(data.workspaceId, '/runtimes'), { const publicBundle = parseRuntimePublicBundle(runtimePublicBundle);
method: 'POST', const currentFingerprint = await previewRuntimePublicKeyFingerprint(publicBundle.public_key);
headers: { 'content-type': 'application/json' }, if (
body: JSON.stringify({ runtimeFingerprint !== currentFingerprint ||
runtime_id: runtimeId, fingerprintConfirmation.trim() !== currentFingerprint
) {
throw new Error('Preview and confirm the exact Runtime public key fingerprint before registration');
}
await createRemoteRuntime(data.workspaceId, {
public_bundle: publicBundle,
display_name: displayName || null, display_name: displayName || null,
endpoint, endpoint,
}), expected_revision: null,
}); });
if (!response.ok) throw new Error(await responseError(response)); runtimePublicBundle = '';
runtimeId = ''; runtimeFingerprint = null;
fingerprintConfirmation = '';
displayName = ''; displayName = '';
endpoint = ''; endpoint = '';
showAddRuntime = false; showAddRuntime = false;
await invalidateAll(); await invalidateAll();
} catch (error) { } catch (error) {
requestError = error instanceof Error ? error.message : String(error); requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
? error.message
: String(error);
} finally { } finally {
busyRuntimeId = null; busyRuntimeId = null;
} }
} }
function currentTestResult(
runtime: WorkspaceRuntimeResource,
): RuntimeConnectionTestResponse | undefined {
const result = testResults[runtime.runtime_id];
return result?.binding_revision === runtime.management?.binding?.revision
? result
: undefined;
}
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> { async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
const bindingRevision = runtime.management?.binding?.revision;
if (typeof bindingRevision !== 'number') return;
const generation = ++connectionTestGeneration;
requestError = null; requestError = null;
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 (
generation !== connectionTestGeneration ||
result.binding_revision !== bindingRevision
) {
await invalidateAll();
return;
}
testResults = { ...testResults, [runtime.runtime_id]: result }; testResults = { ...testResults, [runtime.runtime_id]: result };
await invalidateAll();
} catch (error) { } catch (error) {
if (generation === connectionTestGeneration) {
requestError = error instanceof Error ? error.message : String(error); requestError = error instanceof Error ? error.message : String(error);
}
} finally { } finally {
if (generation === connectionTestGeneration) {
busyRuntimeId = null; busyRuntimeId = null;
} }
} }
}
</script> </script>
<svelte:head> <svelte:head>
@@ -116,10 +207,36 @@
<form class="settings-runtime-form" onsubmit={addRuntime}> <form class="settings-runtime-form" onsubmit={addRuntime}>
<h2>Add remote Runtime</h2> <h2>Add remote Runtime</h2>
<div class="settings-form-grid"> <div class="settings-form-grid">
<label> <label class="settings-form-wide">
Runtime ID Runtime public bundle
<input bind:value={runtimeId} required autocomplete="off" /> <small>Run <code>yoi-runtime identity show --json</code> on the Runtime host and paste the result.</small>
<textarea
bind:value={runtimePublicBundle}
oninput={() => {
runtimeFingerprint = null;
fingerprintConfirmation = '';
}}
required
rows="5"
spellcheck="false"
placeholder={runtimeBundlePlaceholder}
></textarea>
<button type="button" disabled={busyRuntimeId !== null} onclick={previewRuntimeFingerprint}>
Preview fingerprint
</button>
</label> </label>
{#if runtimeFingerprint}
<label>
Runtime key fingerprint
<code>{runtimeFingerprint}</code>
<input
bind:value={fingerprintConfirmation}
required
autocomplete="off"
placeholder="Enter the fingerprint exactly"
/>
</label>
{/if}
<label> <label>
Display name Display name
<input bind:value={displayName} autocomplete="off" /> <input bind:value={displayName} autocomplete="off" />
@@ -129,8 +246,30 @@
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" /> <input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
</label> </label>
</div> </div>
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-trust-heading">
<h3 id="runtime-trust-heading">Trust this Workspace on the Runtime</h3>
{#if data.signingIdentityError}
<p class="section-state error">{data.signingIdentityError}</p>
{:else if data.signingIdentity?.public_bundle}
<p>
Save this public bundle as <code>workspace-public-bundle.json</code> on the Runtime host.
It contains no private key material.
</p>
<pre>{workspacePublicBundle()}</pre>
<button type="button" onclick={copyWorkspaceBundle}>Copy Workspace public bundle</button>
<pre>yoi-runtime trust-workspace add --bundle workspace-public-bundle.json</pre>
<p>
Runtime registration remains <code>configured</code> until authenticated verification is completed.
</p>
{:else}
<p class="section-state">Loading Workspace public identity…</p>
{/if}
</section>
<div class="settings-action-row"> <div class="settings-action-row">
<button type="submit" disabled={busyRuntimeId !== null}>Add Runtime</button> <button
type="submit"
disabled={busyRuntimeId !== null || !runtimeFingerprint || fingerprintConfirmation.trim() !== runtimeFingerprint}
>Add Runtime</button>
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}> <button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
Cancel Cancel
</button> </button>
@@ -174,7 +313,9 @@
<small><code>{runtime.runtime_id}</code></small> <small><code>{runtime.runtime_id}</code></small>
</td> </td>
<td>{runtime.kind}</td> <td>{runtime.kind}</td>
<td>{runtime.status}</td> <td>
{runtime.management?.binding?.connection_state ?? runtime.status}
</td>
<td>{runtimePlatform(runtime)}</td> <td>{runtimePlatform(runtime)}</td>
<td>{managementLabel(runtime)}</td> <td>{managementLabel(runtime)}</td>
<td> <td>
@@ -184,20 +325,23 @@
</td> </td>
<td> <td>
<div class="settings-action-row"> <div class="settings-action-row">
{#if runtime.management?.config_managed} {#if runtime.management?.config_managed && runtime.management.binding?.connection_state !== 'revoked'}
<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?.config_managed} {#if runtime.management?.binding?.state === 'configured'}
<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>
</td> </td>
</tr> </tr>
{#if runtime.diagnostics.length > 0 || testResults[runtime.runtime_id]} {@const currentResult = currentTestResult(runtime)}
{#if runtime.diagnostics.length > 0 || currentResult}
<tr class="settings-runtime-detail-row"> <tr class="settings-runtime-detail-row">
<td colspan="7"> <td colspan="7">
{#if runtime.diagnostics.length > 0} {#if runtime.diagnostics.length > 0}
@@ -210,14 +354,13 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
{#if testResults[runtime.runtime_id]} {#if currentResult}
{@const result = testResults[runtime.runtime_id]} <div class:failed={currentResult.status === 'failed'} class="settings-test-result">
<div class:failed={result.status === 'failed'} class="settings-test-result"> <strong>Connection test: {connectionTestSummary(currentResult)}</strong>
<strong>Connection test: {connectionTestSummary(result)}</strong> {#if currentResult.diagnostics[0]}
{#if result.diagnostics[0]} <span>{currentResult.diagnostics[0].message}</span>
<span>{result.diagnostics[0].message}</span>
{/if} {/if}
<small>Checked {new Date(result.checked_at).toLocaleString()}</small> <small>Checked {new Date(currentResult.checked_at).toLocaleString()}</small>
</div> </div>
{/if} {/if}
</td> </td>
@@ -1,5 +1,6 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management"; import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
import { parseWorkspaceSigningIdentityResponse } from "$lib/workspace/settings/profile-api";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
@@ -16,9 +17,24 @@ export const load: PageLoad = async ({ fetch, params }) => {
}, },
); );
const signingIdentity = await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/signing-identity"),
undefined,
(value) => {
const response = parseWorkspaceSigningIdentityResponse(value);
if (response.identity.workspace_id !== params.workspaceId) {
throw new Error("Workspace signing identity did not match the route");
}
return response;
},
);
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
runtimes: runtimes.data, runtimes: runtimes.data,
runtimesError: runtimes.error, runtimesError: runtimes.error,
signingIdentity: signingIdentity.data,
signingIdentityError: signingIdentity.error,
}; };
}; };
@@ -1,13 +1,12 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import type { import type {
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyStatus, RuntimeTrustKeyStatus,
} from '$lib/generated/workspace-api'; } from '$lib/generated/workspace-api';
import { import {
createRemoteRuntime,
previewRuntimePublicKeyFingerprint, previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey,
revealRuntimeTrustKey, revealRuntimeTrustKey,
revokeRuntimeTrustKey, revokeRuntimeTrustKey,
RuntimeTrustConflictError, RuntimeTrustConflictError,
@@ -144,15 +143,24 @@
} }
} }
const request: PutRuntimeTrustKeyRequest = {
public_key: key,
expected_revision: trust.revision ?? null,
};
const operation = routeFence.capture(data.runtimeId); const operation = routeFence.capture(data.runtimeId);
busyAction = 'save'; busyAction = 'save';
try { try {
await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request); const binding = data.runtimeDetail.runtime.management.binding;
if (!binding || !data.runtimeDetail.endpoint) {
throw new RuntimeTrustRequestError(
'The Workspace identity binding and authoritative Runtime endpoint are required.',
);
}
await createRemoteRuntime(data.workspaceId, {
public_bundle: {
identity_id: operation.runtimeId,
public_key: key,
},
display_name: data.runtimeDetail.runtime.label,
endpoint: data.runtimeDetail.endpoint,
expected_revision: binding.revision,
});
if (!isCurrentRoute(operation)) return; if (!isCurrentRoute(operation)) return;
publicKey = ''; publicKey = '';
fingerprintConfirmation = ''; fingerprintConfirmation = '';
@@ -315,7 +323,12 @@
<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>Status</dt><dd>{runtime.status}</dd></div>
<div><dt>Binding status</dt><dd>{trust.status}</dd></div> <div><dt>Connection state</dt><dd>{runtime.management.binding?.connection_state ?? 'Not configured'}</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>Verified binding revision</dt><dd>{runtime.management.binding?.verification?.binding_revision?.toString() ?? '—'}</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>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div> <div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div> <div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div> <div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
@@ -5,6 +5,7 @@
WorkspaceDeletionPreflightResponse, WorkspaceDeletionPreflightResponse,
WorkspaceDeletionRequest, WorkspaceDeletionRequest,
WorkspaceMetadataSettingsResponse, WorkspaceMetadataSettingsResponse,
WorkspaceSigningIdentityResponse,
} from '$lib/generated/workspace-api'; } from '$lib/generated/workspace-api';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@@ -18,6 +19,8 @@
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte'; import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import { import {
fetchWorkspaceMetadata, fetchWorkspaceMetadata,
fetchWorkspaceSigningIdentity,
provisionWorkspaceSigningIdentity,
updateWorkspaceMetadata, updateWorkspaceMetadata,
} from '$lib/workspace/settings/profile-api'; } from '$lib/workspace/settings/profile-api';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
@@ -26,6 +29,14 @@
let workspaceId = $derived(data.workspace?.workspace_id ?? ''); let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null); let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
let identityLoading = $state(true);
let identityError = $state<string | null>(null);
let provisioningIdentity = $state(false);
let identityCopied = $state(false);
let identityBundleText = $derived(
signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : ''
);
let displayNameDraft = $state(''); let displayNameDraft = $state('');
let loading = $state(true); let loading = $state(true);
let submitting = $state(false); let submitting = $state(false);
@@ -58,6 +69,17 @@
workspaceMetadata = response; workspaceMetadata = response;
displayNameDraft = response.display_name; displayNameDraft = response.display_name;
diagnostics = response.diagnostics; diagnostics = response.diagnostics;
if (data.workspace?.permissions.delete_workspace) {
try {
signingIdentity = await fetchWorkspaceSigningIdentity(workspaceId);
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity request failed';
} finally {
identityLoading = false;
}
} else {
identityLoading = false;
}
} }
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
@@ -93,6 +115,30 @@
} }
} }
async function provisionIdentity() {
provisioningIdentity = true;
identityError = null;
try {
signingIdentity = await provisionWorkspaceSigningIdentity(workspaceId);
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity provisioning failed';
} finally {
provisioningIdentity = false;
}
}
async function copyIdentityBundle() {
const bundle = signingIdentity?.public_bundle;
if (!bundle) return;
identityCopied = false;
try {
await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
identityCopied = true;
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity bundle copy failed';
}
}
async function openDeletionConfirmation() { async function openDeletionConfirmation() {
deletionOpen = true; deletionOpen = true;
deletionLoading = true; deletionLoading = true;
@@ -232,6 +278,52 @@
</section> </section>
{#if data.workspace?.permissions.delete_workspace} {#if data.workspace?.permissions.delete_workspace}
<section class="settings-section" aria-labelledby="workspace-identity-title">
<div class="section-heading">
<div>
<h2 id="workspace-identity-title">Workspace public identity</h2>
<p>Use this public bundle when connecting a Runtime to this Workspace.</p>
</div>
{#if signingIdentity?.public_bundle}
<button type="button" onclick={() => void copyIdentityBundle()}>
{identityCopied ? 'Copied' : 'Copy bundle'}
</button>
{/if}
</div>
{#if identityError}
<p class="status-message error">{identityError}</p>
{/if}
{#if identityLoading}
<p>Loading identity…</p>
{:else if signingIdentity?.identity.state === 'pending_provisioning'}
<p>This existing Workspace needs one explicit signing identity provisioning operation.</p>
<button
type="button"
disabled={provisioningIdentity}
onclick={() => void provisionIdentity()}
>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
{:else if signingIdentity?.public_bundle}
<dl class="metadata-list">
<div>
<dt>Key</dt>
<dd><code>{signingIdentity.identity.key_id}</code></dd>
</div>
<div>
<dt>Fingerprint</dt>
<dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{signingIdentity.identity.revision}</code></dd>
</div>
</dl>
<label class="identity-bundle">
<span>Public identity bundle</span>
<textarea readonly rows="9" value={identityBundleText}></textarea>
</label>
{/if}
</section>
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title"> <section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
<div> <div>
<h2 id="workspace-danger-title">Danger zone</h2> <h2 id="workspace-danger-title">Danger zone</h2>
@@ -286,6 +378,13 @@
{/if} {/if}
<style> <style>
.section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); }
.section-heading p { margin-block: var(--space-1) 0; }
.metadata-list { display: grid; gap: var(--space-2); }
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.75rem; }
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); } .danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
.danger-zone p { max-width: 68ch; } .danger-zone p { max-width: 68ch; }
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); } .danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
+73
View File
@@ -28,6 +28,7 @@ import {
fetchWorkspaceMetadata, fetchWorkspaceMetadata,
parseProfileSettingsResponse, parseProfileSettingsResponse,
parseWorkspaceMetadataSettingsResponse, parseWorkspaceMetadataSettingsResponse,
parseWorkspaceSigningIdentityResponse,
ProfileApiError, ProfileApiError,
updateWorkspaceMetadata, updateWorkspaceMetadata,
} from "../src/lib/workspace/settings/profile-api.ts"; } from "../src/lib/workspace/settings/profile-api.ts";
@@ -170,6 +171,78 @@ Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid
); );
}); });
Deno.test("Workspace signing identity parser validates active and pending public contracts", () => {
const active = {
identity: {
workspace_id: "workspace-1",
key_id: "WK-1",
algorithm: "ed25519",
public_key: "public-key",
public_key_fingerprint: "sha256:fingerprint",
revision: 1,
state: "active",
created_at: "2026-01-01T00:00:00Z",
provisioned_at: "2026-01-01T00:00:00Z",
},
public_bundle: {
workspace_id: "workspace-1",
backend_url: "https://backend.example.test",
key_id: "WK-1",
algorithm: "ed25519",
public_key: "public-key",
public_key_fingerprint: "sha256:fingerprint",
revision: 1,
},
};
assertEquals(
parseWorkspaceSigningIdentityResponse(active).public_bundle?.key_id,
"WK-1",
);
assertEquals(
parseWorkspaceSigningIdentityResponse({
identity: {
workspace_id: "workspace-1",
key_id: "WK-1",
algorithm: "ed25519",
revision: 1,
state: "pending_provisioning",
created_at: "2026-01-01T00:00:00Z",
},
}).public_bundle,
undefined,
);
for (
const mutate of [
(value: Record<string, unknown>) => {
value.private_material_ref = "must-not-be-accepted";
},
(value: Record<string, unknown>) => {
(value.identity as Record<string, unknown>).revision =
Number.MAX_SAFE_INTEGER + 1;
},
(value: Record<string, unknown>) => {
(value.identity as Record<string, unknown>).public_key = "x".repeat(
17_000,
);
},
(value: Record<string, unknown>) => {
(value.public_bundle as Record<string, unknown>).key_id = "WK-other";
},
(value: Record<string, unknown>) => {
delete value.public_bundle;
},
]
) {
const value = structuredClone(active);
mutate(value);
assertThrows(
() => parseWorkspaceSigningIdentityResponse(value),
ProfileApiError,
);
}
});
Deno.test("workspace metadata parser rejects incomplete or stale response fields", () => { Deno.test("workspace metadata parser rejects incomplete or stale response fields", () => {
assertThrows( assertThrows(
() => () =>
@@ -19,6 +19,9 @@ function compatibleResponse(): Record<string, unknown> {
return { return {
workspace_id: "workspace-a", workspace_id: "workspace-a",
runtime_id: "runtime-a", runtime_id: "runtime-a",
binding_revision: 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,
@@ -57,6 +60,23 @@ Deno.test("runtime connection response rejects unknown fields and incoherent com
}), }),
null, null,
); );
assertEquals(
parseRuntimeConnectionTestResponse({
...compatibleResponse(),
verification: {
verified_at: "2026-09-01T12:00:00Z",
last_checked_at: "2026-09-01T12:00:01Z",
last_outcome: "verified",
binding_revision: 2,
workspace_key_id: "WK-a",
workspace_identity_revision: 1,
workspace_trust_generation: 1,
runtime_public_key_fingerprint: "sha256:runtime",
runtime_identity_revision: 1,
},
}),
null,
);
}); });
Deno.test("runtime connection response rejects unknown failure kinds and unbounded diagnostics", () => { Deno.test("runtime connection response rejects unknown failure kinds and unbounded diagnostics", () => {
+59 -32
View File
@@ -3,12 +3,12 @@ declare const Deno: {
}; };
import { import {
createRemoteRuntime,
parseRuntimeTrustConflict, parseRuntimeTrustConflict,
parseRuntimeTrustKeyRevealResponse, parseRuntimeTrustKeyRevealResponse,
parseWorkspaceRuntimeDetail, parseWorkspaceRuntimeDetail,
parseWorkspaceRuntimeList, parseWorkspaceRuntimeList,
previewRuntimePublicKeyFingerprint, previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey,
revokeRuntimeTrustKey, revokeRuntimeTrustKey,
RuntimeTrustConflictError, RuntimeTrustConflictError,
RuntimeTrustRouteFence, RuntimeTrustRouteFence,
@@ -39,6 +39,24 @@ function runtime() {
removable: false, removable: false,
endpoint_configured: true, endpoint_configured: true,
token_ref_configured: false, token_ref_configured: false,
binding: {
state: "verified",
connection_state: "verified",
revision: 3,
workspace_key_id: "WK-1",
workspace_key_generation: 1,
verification: {
verified_at: "2026-09-01T13:00:00Z",
last_checked_at: "2026-09-01T13:00:00Z",
last_outcome: "verified",
binding_revision: 3,
workspace_key_id: "WK-1",
workspace_identity_revision: 1,
workspace_trust_generation: 1,
runtime_public_key_fingerprint: "SHA256:current",
runtime_identity_revision: 1,
},
},
}, },
runtime_id: "arcadia", runtime_id: "arcadia",
label: "Arcadia", label: "Arcadia",
@@ -95,6 +113,11 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
"Runtime ID was not preserved", "Runtime ID was not preserved",
); );
assert(
list.items[0]?.management.binding?.state === "verified",
"binding state was not preserved",
);
const parsed = parseWorkspaceRuntimeDetail(detail()); const parsed = parseWorkspaceRuntimeDetail(detail());
assert( assert(
parsed.trust_key.revision === 3, parsed.trust_key.revision === 3,
@@ -106,6 +129,20 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
); );
}); });
Deno.test("Runtime management parser rejects Workspace identity bindings without key metadata", () => {
const payload = detail();
const binding = payload.runtime.management.binding as Partial<
typeof payload.runtime.management.binding
>;
delete binding.workspace_key_id;
delete binding.workspace_key_generation;
binding.state = "configured";
assertThrows(
() => parseWorkspaceRuntimeDetail(payload),
"requires Workspace signing key identity metadata",
);
});
Deno.test("Runtime validators reject unknown object keys and enum variants", () => { Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
assertThrows( assertThrows(
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }), () => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }),
@@ -240,48 +277,38 @@ Deno.test("Runtime public key preview matches the Server fingerprint contract",
); );
}); });
Deno.test("typed trust conflict is validated and preserves authoritative revision", async () => { Deno.test("Runtime create surfaces bounded Settings error details", async () => {
let sentBody: unknown = null; const fetchImpl = (() =>
const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => { Promise.resolve(
sentBody = JSON.parse(String(init?.body)) as unknown;
return Promise.resolve(
new Response( new Response(
JSON.stringify({ JSON.stringify({
error: "stale_revision", error: "remote_runtime_endpoint_not_allowed",
message: "Runtime trust changed", details: "Runtime endpoint must use public https egress",
current_revision: 4,
current_fingerprint: "SHA256:new",
}), }),
{ status: 409, headers: { "content-type": "application/json" } }, { status: 400, headers: { "content-type": "application/json" } },
), ),
); )) as typeof fetch;
}) as typeof fetch;
try { try {
await putRuntimeTrustKey( await createRemoteRuntime(
"workspace-a", "workspace-a",
"arcadia", {
{ public_key: "ssh-ed25519 AAAA-new", expected_revision: 3 }, public_bundle: {
identity_id: "runtime-a",
public_key: "yoi-ed25519-pub:v1:test",
},
display_name: null,
endpoint: "https://runtime.example",
expected_revision: null,
},
fetchImpl, fetchImpl,
); );
throw new Error("expected mutation to reject"); throw new Error("expected create to reject");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error);
assert( assert(
error instanceof RuntimeTrustConflictError, message === "Runtime endpoint must use public https egress",
"expected typed conflict", `unexpected create error: ${message}`,
);
assert(
error.conflict.current_revision === 4,
"authoritative revision was lost",
); );
} }
assert(
JSON.stringify(sentBody) ===
JSON.stringify({
public_key: "ssh-ed25519 AAAA-new",
expected_revision: 3,
}),
"request should serialize the generated bigint revision as a safe JSON integer",
);
}); });