refactor: remove server-global runtime trust

This commit is contained in:
2026-09-08 12:19:39 +09:00
parent fae36d220d
commit 3344d9f8b2
19 changed files with 269 additions and 2714 deletions
+4 -16
View File
@@ -12,10 +12,10 @@ use workspace_api::{
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse,
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
};
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(
&self,
runtime_id: &str,
-185
View File
@@ -10,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub: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";
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
@@ -164,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)]
pub struct RuntimeAuthContext {
pub server_id: String,
@@ -188,122 +171,6 @@ pub struct RuntimeAuthContext {
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)]
pub struct RuntimeRequestSourceClaims {
pub iss: String,
@@ -638,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 {
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
}
@@ -903,46 +760,4 @@ mod tests {
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 { .. })
));
}
}
+10 -501
View File
@@ -6,10 +6,7 @@
//! Runtime process directly; a backend is expected to own any browser-facing
//! credentials, registration, and policy.
use crate::auth::{
RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig, new_token_id, unix_now_seconds,
verify_capability_token,
};
use crate::auth::{RuntimeAuthContext, new_token_id, unix_now_seconds};
use crate::catalog::{
ConfigBundleRef, CreateWorkerRequest, RepositoryRefObservationRequest, WorkerDetail,
WorkerLifecycleAck, WorkerSummary, WorkingDirectoryRepositoryAccessRequest,
@@ -95,11 +92,9 @@ pub struct RuntimeHttpServerConfig {
pub display_name: Option<String>,
/// v0 store selection for the Runtime process.
pub store: RuntimeHttpStoreSelection,
/// Minimal local bearer token placeholder for backend-to-Runtime calls.
/// Minimal local bearer token for explicitly local Runtime calls.
/// This is not a browser-facing credential model.
pub local_token: Option<String>,
/// Optional signed Server-to-Runtime capability token authority.
pub auth: Option<RuntimeHttpAuthConfig>,
}
impl Default for RuntimeHttpServerConfig {
@@ -109,7 +104,6 @@ impl Default for RuntimeHttpServerConfig {
display_name: None,
store: RuntimeHttpStoreSelection::Memory,
local_token: None,
auth: None,
}
}
}
@@ -152,34 +146,15 @@ pub async fn serve_runtime_http(
Ok(())
}
/// Serve an existing Runtime on a pre-bound listener with signed capability-token auth.
pub async fn serve_runtime_http_with_auth(
runtime: Runtime,
listener: TcpListener,
local_token: Option<String>,
auth: Option<RuntimeHttpAuthConfig>,
) -> Result<(), RuntimeHttpServerError> {
if local_token.is_none() && auth.is_none() {
return Err(RuntimeHttpServerError::AuthRequired);
}
axum::serve(
listener,
runtime_http_router_with_optional_auth(runtime, local_token, auth, None),
)
.await?;
Ok(())
}
pub async fn serve_runtime_http_with_workspace_auth(
runtime: Runtime,
listener: TcpListener,
local_token: Option<String>,
auth: Option<RuntimeHttpAuthConfig>,
workspace_auth: WorkspaceRuntimeHttpAuth,
) -> Result<(), RuntimeHttpServerError> {
axum::serve(
listener,
runtime_http_router_with_optional_auth(runtime, local_token, auth, Some(workspace_auth)),
runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth)),
)
.await?;
Ok(())
@@ -191,37 +166,25 @@ pub async fn serve_runtime_http_with_workspace_auth(
/// The path contains only a Runtime-local `worker_id`; backend aliases are not
/// accepted or forwarded as Runtime authority.
pub fn runtime_http_router(runtime: Runtime, local_token: String) -> Router {
runtime_http_router_with_optional_auth(runtime, Some(local_token), None, None)
}
/// Build the REST router for an existing Runtime with signed capability-token auth.
pub fn runtime_http_router_with_auth(
runtime: Runtime,
local_token: Option<String>,
auth: RuntimeHttpAuthConfig,
) -> Router {
runtime_http_router_with_optional_auth(runtime, local_token, Some(auth), None)
runtime_http_router_with_optional_auth(runtime, Some(local_token), None)
}
pub fn runtime_http_router_with_workspace_auth(
runtime: Runtime,
local_token: Option<String>,
auth: RuntimeHttpAuthConfig,
workspace_auth: WorkspaceRuntimeHttpAuth,
) -> Router {
runtime_http_router_with_optional_auth(runtime, local_token, Some(auth), Some(workspace_auth))
runtime_http_router_with_optional_auth(runtime, local_token, Some(workspace_auth))
}
fn runtime_http_router_with_optional_auth(
runtime: Runtime,
local_token: Option<String>,
auth: Option<RuntimeHttpAuthConfig>,
workspace_auth: Option<WorkspaceRuntimeHttpAuth>,
) -> Router {
let state = RuntimeHttpState {
runtime,
local_token: local_token.map(Arc::<str>::from),
auth: auth.map(Arc::new),
workspace_auth: workspace_auth.map(Arc::new),
workdir_sessions: Arc::new(Mutex::new(HashMap::new())),
};
@@ -328,7 +291,6 @@ pub const MAX_WORKER_FILE_UPLOAD_BYTES: usize =
struct RuntimeHttpState {
runtime: Runtime,
local_token: Option<Arc<str>>,
auth: Option<Arc<RuntimeHttpAuthConfig>>,
workspace_auth: Option<Arc<WorkspaceRuntimeHttpAuth>>,
workdir_sessions: Arc<Mutex<HashMap<String, RuntimeHttpWorkdirSession>>>,
}
@@ -709,9 +671,9 @@ async fn get_runtime_ping(
));
}
let runtime_id = state
.auth
.workspace_auth
.as_ref()
.map(|config| config.runtime_id.trim())
.map(|auth| auth.signer.runtime_id().trim())
.filter(|runtime_id| !runtime_id.is_empty())
.ok_or_else(|| {
RuntimeHttpRestError::new(
@@ -2110,56 +2072,6 @@ async fn require_runtime_auth(
}
}
if let Some(auth) = state.auth.as_deref() {
let Some(token) = supplied.as_deref() else {
return RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"unauthorized",
"missing Runtime capability bearer token",
)
.into_response();
};
match verify_capability_token(
auth,
token,
required_runtime_permission(request.method(), request.uri().path()),
unix_now_seconds(),
) {
Ok(context) => {
let workspace_verification_exists = match state.workspace_auth.as_deref() {
Some(workspace_auth) => match workspace_auth
.verifications
.get(&context.workspace_id, workspace_auth.signer.runtime_id())
{
Ok(record) => record.is_some(),
Err(error) => {
return RuntimeHttpRestError::new(
StatusCode::SERVICE_UNAVAILABLE,
"workspace_runtime_verification_unavailable",
error.to_string(),
)
.into_response();
}
},
None => false,
};
if workspace_verification_exists {
return RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_identity_required",
"Legacy Server-issued capability is disabled after signed Workspace Runtime verification",
)
.into_response();
}
request.extensions_mut().insert(context);
return next.run(request).await;
}
Err(error) => {
return runtime_auth_error_response(error).into_response();
}
}
}
if let Some(expected) = state.local_token.as_deref() {
if supplied.as_deref() != Some(expected) {
return RuntimeHttpRestError::new(
@@ -2180,32 +2092,12 @@ async fn require_runtime_auth(
next.run(request).await
}
fn runtime_auth_error_response(error: RuntimeAuthError) -> RuntimeHttpRestError {
match error {
RuntimeAuthError::MissingPermission(permission) => RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"forbidden",
format!("Runtime capability token is missing required permission `{permission}`"),
),
RuntimeAuthError::MissingWorkspaceScope => RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_scope_required",
"Runtime capability token is missing workspace scope",
),
other => RuntimeHttpRestError::new(
StatusCode::UNAUTHORIZED,
"unauthorized",
format!("invalid Runtime capability token: {other}"),
),
}
}
fn auth_workspace_scope(
state: &RuntimeHttpState,
auth: Option<&Extension<RuntimeAuthContext>>,
) -> Result<Option<RuntimeWorkspaceScope>, RuntimeHttpRestError> {
let Some(Extension(context)) = auth else {
if state.auth.is_some() || state.local_token.is_some() {
if state.workspace_auth.is_some() || state.local_token.is_some() {
return Err(RuntimeHttpRestError::new(
StatusCode::FORBIDDEN,
"workspace_scope_required",
@@ -2537,7 +2429,7 @@ fn code_for_runtime_error(error: &RuntimeError) -> String {
pub enum RuntimeHttpServerError {
#[error(transparent)]
Runtime(#[from] RuntimeError),
#[error("Runtime HTTP server requires capability-token auth or a local bearer token")]
#[error("Runtime HTTP server requires Workspace issuer auth or a local bearer token")]
AuthRequired,
#[error("Runtime HTTP server I/O failed: {0}")]
Io(#[from] std::io::Error),
@@ -2546,10 +2438,7 @@ pub enum RuntimeHttpServerError {
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{
CapabilityTokenSigner, RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey,
capability_claims,
};
use crate::auth::RuntimeIdentityMaterial;
use crate::catalog::{ConfigBundleRef, ProfileSelector, WorkerStatus, WorkspaceApiRef};
use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
@@ -2579,7 +2468,6 @@ mod tests {
#[tokio::test]
async fn workspace_signed_verification_requires_exact_request_and_acknowledges_response() {
let runtime = Runtime::new_memory();
let (legacy_auth, _) = auth_config_and_signer();
let workspace_identity = RuntimeIdentityMaterial::generate("workspace-key").unwrap();
let runtime_identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let workspace_public_key =
@@ -2614,7 +2502,6 @@ mod tests {
let app = runtime_http_router_with_workspace_auth(
runtime,
None,
legacy_auth,
WorkspaceRuntimeHttpAuth {
verifier,
signer: RuntimeVerificationSigner::from_identity(&runtime_identity).unwrap(),
@@ -2764,82 +2651,6 @@ mod tests {
assert_eq!(response.status(), StatusCode::OK);
}
#[tokio::test]
async fn ping_requires_scoped_permission_and_returns_versioned_identity() {
let runtime = Runtime::new_memory();
let (auth, signer) = auth_config_and_signer();
let app = runtime_http_router_with_auth(runtime, None, auth);
let token =
token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]);
let request = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap();
let response = app.clone().oneshot(request).await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
serde_json::from_slice::<RuntimeHttpPingResponse>(&body).unwrap(),
RuntimeHttpPingResponse {
runtime_id: "runtime-test".to_string(),
protocol_version: RUNTIME_HTTP_PROTOCOL_VERSION,
}
);
let wrong_scope_token =
token_for_workspace_with_permissions(&signer, "workspace-a", [RUNTIME_PING_PERMISSION]);
let wrong_scope_request = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {wrong_scope_token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-b")
.body(Body::empty())
.unwrap();
assert_eq!(
app.oneshot(wrong_scope_request).await.unwrap().status(),
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn ping_rejects_token_without_ping_permission() {
let runtime = Runtime::new_memory();
let (auth, signer) = auth_config_and_signer();
let app = runtime_http_router_with_auth(runtime, None, auth);
let missing_credential = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap();
assert_eq!(
app.clone()
.oneshot(missing_credential)
.await
.unwrap()
.status(),
StatusCode::UNAUTHORIZED
);
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:read"]);
let request = Request::builder()
.method(Method::GET)
.uri("/v1/ping")
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(RUNTIME_WORKSPACE_SCOPE_HEADER, "workspace-a")
.body(Body::empty())
.unwrap();
assert_eq!(
app.oneshot(request).await.unwrap().status(),
StatusCode::FORBIDDEN
);
}
#[test]
fn runtime_protocol_replaces_serialized_tracked_source() {
let wire = serde_json::to_string(&protocol::Method::SubmitTracked {
@@ -2937,301 +2748,6 @@ mod tests {
.with_computed_digest()
}
fn store_coder_test_bundle(runtime: &Runtime) {
runtime
.store_config_bundle(test_bundle(ProfileSelector::Builtin(
"builtin:coder".to_string(),
)))
.unwrap();
}
fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
let mut request = task_request(objective);
request.workspace_api = Some(WorkspaceApiRef {
workspace_id: workspace_id.to_string(),
base_url: format!("https://workspace.example/{workspace_id}"),
});
request.memory_settings = Some(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: workspace_id.to_string(),
settings_revision: 1,
language: "English".to_string(),
});
request
}
fn auth_config_and_signer() -> (RuntimeHttpAuthConfig, CapabilityTokenSigner) {
let identity = RuntimeIdentityMaterial::generate("server-a").unwrap();
let signer = CapabilityTokenSigner::new(identity.identity_id.clone(), identity.private_key);
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-test".to_string(),
trusted_servers: vec![TrustedServerKey {
server_id: identity.identity_id,
public_key: identity.public_key,
display_name: None,
}],
};
(auth, signer)
}
fn auth_config_and_two_signers() -> (
RuntimeHttpAuthConfig,
CapabilityTokenSigner,
CapabilityTokenSigner,
) {
let identity_a = RuntimeIdentityMaterial::generate("server-a").unwrap();
let identity_b = RuntimeIdentityMaterial::generate("server-b").unwrap();
let signer_a =
CapabilityTokenSigner::new(identity_a.identity_id.clone(), identity_a.private_key);
let signer_b =
CapabilityTokenSigner::new(identity_b.identity_id.clone(), identity_b.private_key);
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-test".to_string(),
trusted_servers: vec![
TrustedServerKey {
server_id: identity_a.identity_id,
public_key: identity_a.public_key,
display_name: None,
},
TrustedServerKey {
server_id: identity_b.identity_id,
public_key: identity_b.public_key,
display_name: None,
},
],
};
(auth, signer_a, signer_b)
}
fn token_for_workspace(signer: &CapabilityTokenSigner, workspace_id: &str) -> String {
token_for_workspace_with_permissions(
signer,
workspace_id,
[
"workers:list",
"workers:create",
"workers:read",
"workers:input",
"workers:stop",
"workers:protocol",
"workers:delete",
"workdirs:operate",
],
)
}
fn token_for_workspace_with_permissions<const N: usize>(
signer: &CapabilityTokenSigner,
workspace_id: &str,
permissions: [&str; N],
) -> String {
let claims = capability_claims(
signer.server_id(),
"runtime-test",
workspace_id,
permissions.into_iter().map(str::to_string).collect(),
3600,
)
.unwrap();
signer.sign(&claims).unwrap()
}
fn bearer_request(
method: Method,
uri: impl AsRef<str>,
token: &str,
body: impl Into<Body>,
) -> Request<Body> {
Request::builder()
.method(method)
.uri(uri.as_ref())
.header(header::AUTHORIZATION, format!("Bearer {token}"))
.header(header::CONTENT_TYPE, "application/json")
.body(body.into())
.unwrap()
}
#[tokio::test]
async fn capability_workspace_scope_filters_list_and_hides_detail() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
store_coder_test_bundle(&runtime);
let (auth, signer) = auth_config_and_signer();
let token_a = token_for_workspace(&signer, "workspace-a");
let token_b = token_for_workspace(&signer, "workspace-b");
let app = runtime_http_router_with_auth(runtime, None, auth);
let create_a = scoped_task_request("a", "workspace-a");
let response = app
.clone()
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token_a,
serde_json::to_vec(&create_a).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let worker_a: RuntimeHttpWorkerResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(worker_a.worker.workspace_id.as_deref(), Some("workspace-a"));
let create_b = scoped_task_request("b", "workspace-b");
let response = app
.clone()
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token_b,
serde_json::to_vec(&create_b).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let worker_b: RuntimeHttpWorkerResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(worker_b.worker.workspace_id.as_deref(), Some("workspace-b"));
let response = app
.clone()
.oneshot(bearer_request(
Method::GET,
"/v1/workers",
&token_a,
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let workers: RuntimeHttpWorkersResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(workers.workers.len(), 1);
assert_eq!(workers.workers[0].worker_ref, worker_a.worker.worker_ref);
assert_eq!(
workers.workers[0].workspace_id.as_deref(),
Some("workspace-a")
);
let response = app
.oneshot(bearer_request(
Method::GET,
format!("/v1/workers/{}", worker_b.worker.worker_id),
&token_a,
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn capability_workspace_owner_binding_rejects_other_trusted_server() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
store_coder_test_bundle(&runtime);
let (auth, signer_a, signer_b) = auth_config_and_two_signers();
let token_a = token_for_workspace(&signer_a, "workspace-a");
let token_b = token_for_workspace(&signer_b, "workspace-a");
let app = runtime_http_router_with_auth(runtime, None, auth);
let create_a = scoped_task_request("a", "workspace-a");
let response = app
.clone()
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token_a,
serde_json::to_vec(&create_a).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let create_b = scoped_task_request("b", "workspace-a");
let response = app
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token_b,
serde_json::to_vec(&create_b).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn capability_token_without_workspace_scope_is_forbidden() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let (auth, signer) = auth_config_and_signer();
let token = token_for_workspace_with_permissions(&signer, "", ["workers:list"]);
let app = runtime_http_router_with_auth(runtime, None, auth);
let response = app
.oneshot(bearer_request(
Method::GET,
"/v1/workers",
&token,
Body::empty(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn capability_token_without_worker_permission_is_forbidden() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let (auth, signer) = auth_config_and_signer();
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:list"]);
let app = runtime_http_router_with_auth(runtime, None, auth);
let create = scoped_task_request("a", "workspace-a");
let response = app
.oneshot(bearer_request(
Method::POST,
"/v1/workers",
&token,
serde_json::to_vec(&create).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn capability_token_without_workdir_permission_is_forbidden() {
let runtime =
Runtime::with_execution_backend(RuntimeOptions::default(), Arc::new(AcceptingBackend))
.unwrap();
let (auth, signer) = auth_config_and_signer();
let token = token_for_workspace_with_permissions(&signer, "workspace-a", ["workers:read"]);
let app = runtime_http_router_with_auth(runtime, None, auth);
let response = app
.oneshot(bearer_request(
Method::POST,
"/v1/working-directories/wd-1/sessions",
&token,
serde_json::to_vec(&OpenWorkdirSessionRequest::default()).unwrap(),
))
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
fn task_request(_objective: &str) -> CreateWorkerRequest {
let profile = ProfileSelector::Builtin("builtin:coder".to_string());
let bundle = test_bundle(profile.clone());
@@ -3331,7 +2847,6 @@ mod tests {
)
.expect("runtime"),
local_token: Some(Arc::from("token")),
auth: None,
workspace_auth: None,
workdir_sessions: Arc::new(Mutex::new(HashMap::from([(
"session-1".to_string(),
@@ -3708,12 +3223,6 @@ mod tests {
.await
.unwrap_err();
assert!(matches!(error, RuntimeHttpServerError::AuthRequired));
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let error = serve_runtime_http_with_auth(Runtime::new_memory(), listener, None, None)
.await
.unwrap_err();
assert!(matches!(error, RuntimeHttpServerError::AuthRequired));
}
#[test]
+48 -227
View File
@@ -15,9 +15,9 @@ use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use worker_runtime::auth::{
RuntimeHttpAuthConfig, RuntimeIdentityMaterial, TrustedServerKey, decode_public_key,
};
use worker_runtime::auth::RuntimeIdentityMaterial;
#[cfg(test)]
use worker_runtime::auth::decode_public_key;
use worker_runtime::error::RuntimeError;
use worker_runtime::fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
use worker_runtime::http_server::{
@@ -81,16 +81,15 @@ fn run() -> Result<(), ProcessError> {
}
if matches!(
args.first().map(String::as_str),
Some("identity" | "trust-server" | "trust-workspace")
Some("identity" | "trust-workspace")
) {
return run_auth_command(args);
}
let Some(mut config) = parse_args(args)? else {
let Some(config) = parse_args(args)? else {
println!("{}", usage());
return Ok(());
};
init_serve_tracing();
config.http.auth = load_runtime_http_auth(&config)?;
let workspace_http_auth = load_workspace_runtime_http_auth(&config)?;
let runtime = tokio::runtime::Builder::new_current_thread()
@@ -108,16 +107,19 @@ fn run() -> Result<(), ProcessError> {
worker_runtime,
listener,
config.http.local_token,
config.http.auth,
workspace_auth,
)
.await
} else {
worker_runtime::http_server::serve_runtime_http_with_auth(
let local_token = config.http.local_token.ok_or_else(|| {
ProcessError::auth(
"Runtime HTTP server requires Workspace issuer auth or --local-token".to_owned(),
)
})?;
worker_runtime::http_server::serve_runtime_http(
worker_runtime,
listener,
config.http.local_token,
config.http.auth,
Some(local_token),
)
.await
};
@@ -207,13 +209,8 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
.with_runtime_store_dir(runtime_store_dir);
let runtime_auth = read_runtime_auth_file(&runtime_auth_path(config))?;
if let Some(identity) = runtime_auth.identity.clone() {
if let [trusted_server] = runtime_auth.trusted_servers.as_slice() {
factory =
factory.with_runtime_request_identity(identity, trusted_server.server_id.clone());
} else {
factory = factory.with_remote_worker_mutation_identity(identity);
}
}
let mut backend_resource_client: Option<
Arc<dyn worker_runtime::resource::BackendResourceClient>,
> = None;
@@ -223,9 +220,9 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
"--backend-resource-endpoint requires a configured Runtime identity".to_owned(),
)
})?;
let [trusted_server] = runtime_auth.trusted_servers.as_slice() else {
let [workspace_issuer] = runtime_auth.workspace_issuers.as_slice() else {
return Err(ProcessError::auth(
"--backend-resource-endpoint requires exactly one trusted Server identity"
"--backend-resource-endpoint requires exactly one trusted Workspace issuer"
.to_owned(),
));
};
@@ -234,7 +231,7 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
endpoint,
config.backend_resource_token.clone(),
)
.with_runtime_request_source(identity, trusted_server.server_id.clone()),
.with_runtime_request_source(identity, workspace_issuer.backend_url.clone()),
);
factory = factory.with_resource_client(client.clone());
backend_resource_client = Some(client);
@@ -254,11 +251,10 @@ fn build_runtime(config: &ProcessConfig) -> Result<Runtime, ProcessError> {
}
RuntimeHttpStoreSelection::Fs { root } => {
let mut options = FsRuntimeStoreOptions::new(root.clone()).with_runtime_id(
config
.http
.auth
runtime_auth
.identity
.as_ref()
.map(|auth| auth.runtime_id.as_str())
.map(|identity| identity.identity_id.as_str())
.unwrap_or("local"),
);
options.display_name = config.http.display_name.clone();
@@ -549,8 +545,6 @@ struct RuntimeAuthFile {
#[serde(default, skip_serializing_if = "Option::is_none")]
identity: Option<RuntimeIdentityMaterial>,
#[serde(default)]
trusted_servers: Vec<TrustedServerKey>,
#[serde(default)]
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
}
@@ -924,23 +918,6 @@ fn load_workspace_runtime_http_auth(
}))
}
fn load_runtime_http_auth(
config: &ProcessConfig,
) -> Result<Option<RuntimeHttpAuthConfig>, ProcessError> {
let path = runtime_auth_path(config);
let auth = read_runtime_auth_file(&path)?;
let Some(identity) = auth.identity else {
return Ok(None);
};
if auth.trusted_servers.is_empty() {
return Ok(None);
}
Ok(Some(RuntimeHttpAuthConfig {
runtime_id: identity.identity_id,
trusted_servers: auth.trusted_servers,
}))
}
fn parse_auth_storage_flags(args: &mut VecDeque<String>) -> Result<ProcessConfig, ProcessError> {
let mut config = ProcessConfig::default()?;
while let Some(arg) = args.pop_front() {
@@ -967,7 +944,6 @@ fn run_auth_command(args: Vec<String>) -> Result<(), ProcessError> {
let command = args.pop_front().unwrap_or_default();
match command.as_str() {
"identity" => run_identity_command(args),
"trust-server" => run_trust_server_command(args),
"trust-workspace" => run_trust_workspace_command(args),
_ => Err(ProcessError::usage(format!(
"unknown auth command `{command}`"
@@ -1096,187 +1072,6 @@ fn run_identity_command(mut args: VecDeque<String>) -> Result<(), ProcessError>
}
}
fn run_trust_server_command(mut args: VecDeque<String>) -> Result<(), ProcessError> {
let subcommand = args.pop_front().ok_or_else(|| {
ProcessError::usage(
"trust-server requires subcommand `add`, `list`, or `revoke`".to_string(),
)
})?;
match subcommand.as_str() {
"add" => {
let mut server_id = None;
let mut public_key = None;
let mut display_name = None;
let mut replace = false;
let mut rest = VecDeque::new();
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)?),
"--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;
}
"--fs-root" | "--fs-runtime-dir" => {
rest.push_back(flag);
if let Some(value) = inline_value {
rest.push_back(value);
} else {
rest.push_back(args.pop_front().ok_or_else(|| {
ProcessError::usage(format!(
"{} requires a value",
rest.back().unwrap()
))
})?);
}
}
_ => {
return Err(ProcessError::usage(format!(
"unknown trust-server add argument `{flag}`"
)));
}
}
}
let config = parse_auth_storage_flags(&mut rest)?;
let path = runtime_auth_path(&config);
let mut auth = read_runtime_auth_file(&path)?;
let server_id = server_id.ok_or_else(|| {
ProcessError::usage("trust-server add requires --server-id".to_string())
})?;
let public_key = public_key.ok_or_else(|| {
ProcessError::usage("trust-server add requires --public-key".to_string())
})?;
decode_public_key(&public_key)
.map_err(|error| ProcessError::usage(error.to_string()))?;
if auth
.trusted_servers
.iter()
.any(|server| server.server_id == server_id)
&& !replace
{
return Err(ProcessError::usage(format!(
"trusted server `{server_id}` already exists; pass --replace to update it"
)));
}
auth.trusted_servers
.retain(|server| server.server_id != server_id);
auth.trusted_servers.push(TrustedServerKey {
server_id: server_id.clone(),
public_key,
display_name,
});
write_runtime_auth_file(&path, &auth)?;
println!("trusted_server_id={server_id}");
println!("auth_file={}", path.display());
Ok(())
}
"list" => {
let mut json = false;
let mut rest = VecDeque::new();
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;
}
"--fs-root" | "--fs-runtime-dir" => {
rest.push_back(flag);
if let Some(value) = inline_value {
rest.push_back(value);
} else {
rest.push_back(args.pop_front().ok_or_else(|| {
ProcessError::usage(format!(
"{} requires a value",
rest.back().unwrap()
))
})?);
}
}
_ => {
return Err(ProcessError::usage(format!(
"unknown trust-server list argument `{flag}`"
)));
}
}
}
let config = parse_auth_storage_flags(&mut rest)?;
let auth = read_runtime_auth_file(&runtime_auth_path(&config))?;
if json {
println!(
"{}",
serde_json::to_string_pretty(&auth.trusted_servers)
.map_err(|error| ProcessError::auth(error.to_string()))?
);
} else {
for server in auth.trusted_servers {
println!(
"server_id={} public_key={} display_name={}",
server.server_id,
server.public_key,
server.display_name.unwrap_or_default()
);
}
}
Ok(())
}
"revoke" => {
let mut server_id = None;
let mut rest = VecDeque::new();
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)?),
"--fs-root" | "--fs-runtime-dir" => {
rest.push_back(flag);
if let Some(value) = inline_value {
rest.push_back(value);
} else {
rest.push_back(args.pop_front().ok_or_else(|| {
ProcessError::usage(format!(
"{} requires a value",
rest.back().unwrap()
))
})?);
}
}
_ => {
return Err(ProcessError::usage(format!(
"unknown trust-server revoke argument `{flag}`"
)));
}
}
}
let config = parse_auth_storage_flags(&mut rest)?;
let path = runtime_auth_path(&config);
let mut auth = read_runtime_auth_file(&path)?;
let server_id = server_id.ok_or_else(|| {
ProcessError::usage("trust-server revoke requires --server-id".to_string())
})?;
let before = auth.trusted_servers.len();
auth.trusted_servers
.retain(|server| server.server_id != server_id);
if auth.trusted_servers.len() == before {
return Err(ProcessError::usage(format!(
"trusted server `{server_id}` is not registered"
)));
}
write_runtime_auth_file(&path, &auth)?;
println!("revoked_server_id={server_id}");
Ok(())
}
_ => Err(ProcessError::usage(format!(
"unknown trust-server subcommand `{subcommand}`"
))),
}
}
fn usage() -> &'static str {
r#"Usage: yoi-runtime [OPTIONS]
yoi-runtime migrate --dry-run [--runtime-id <ID>] [OPTIONS]
@@ -1302,14 +1097,11 @@ Options:
Auth commands:
identity init --runtime-id ID [--replace] [--fs-root PATH] [--fs-runtime-dir PATH]
identity show [--json] [--fs-root PATH] [--fs-runtime-dir PATH]
trust-server add --server-id ID --public-key KEY [--display-name NAME] [--replace] [--fs-root PATH] [--fs-runtime-dir PATH]
trust-server list [--json] [--fs-root PATH] [--fs-runtime-dir PATH]
trust-workspace add --bundle PATH [--fs-root PATH] [--fs-runtime-dir PATH]
trust-workspace list [--offset N] [--limit N] [--fs-root PATH] [--fs-runtime-dir PATH]
trust-workspace show --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH]
trust-workspace replace --bundle PATH [--fs-root PATH] [--fs-runtime-dir PATH]
trust-workspace revoke --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH]
trust-server revoke --server-id ID [--fs-root PATH] [--fs-runtime-dir PATH]"#
trust-workspace revoke --workspace-id ID [--fs-root PATH] [--fs-runtime-dir PATH]"#
}
#[cfg(test)]
@@ -1700,6 +1492,35 @@ mod tests {
);
}
#[test]
fn legacy_server_trust_entries_are_dropped_when_auth_store_is_rewritten() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("runtime-auth.toml");
let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap();
std::fs::write(
&path,
format!(
"identity = {{ identity_id = \"{}\", private_key = \"{}\", public_key = \"{}\" }}\n[[trusted_servers]]\nserver_id = \"removed\"\npublic_key = \"removed\"\n",
identity.identity_id, identity.private_key, identity.public_key
),
)
.unwrap();
let auth = read_runtime_auth_file(&path).unwrap();
write_runtime_auth_file(&path, &auth).unwrap();
let rewritten = std::fs::read_to_string(path).unwrap();
assert!(!rewritten.contains("trusted_servers"));
assert!(!rewritten.contains("server_id"));
assert!(rewritten.contains("identity_id = \"runtime-a\""));
}
#[test]
fn removed_server_trust_command_is_rejected() {
let error = run_auth_command(vec!["trust-server".to_owned(), "list".to_owned()])
.unwrap_err()
.to_string();
assert_eq!(error, "unknown auth command `trust-server`");
}
#[test]
fn no_store_disables_runtime_catalog_persistence() {
let config = parse_args(["--no-store"]).unwrap().unwrap();
+1 -111
View File
@@ -11,7 +11,7 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use workspace_api::WorkspacePublicIdentityBundle;
use crate::auth::{RuntimeAuthContext, RuntimeAuthError, RuntimeHttpAuthConfig};
use crate::auth::RuntimeAuthError;
const WORKSPACE_TOKEN_PREFIX: &str = "yoi-workspace-v1";
const WORKSPACE_SIGNING_INPUT_PREFIX: &str = "yoi.workspace.capability.v1.";
@@ -1408,68 +1408,6 @@ pub(crate) fn hex_lower(bytes: &[u8]) -> String {
output
}
pub enum RuntimeCapabilityVerifier {
LegacyServer(RuntimeHttpAuthConfig),
WorkspaceIssuer(WorkspaceCapabilityVerifier),
}
pub enum RuntimeCapabilityVerification<'a> {
LegacyServer {
required_permission: Option<&'a str>,
now_seconds: u64,
},
WorkspaceIssuer(WorkspaceCapabilityExpectation<'a>),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum VerifiedRuntimeCapability {
LegacyServer(RuntimeAuthContext),
WorkspaceIssuer(VerifiedWorkspaceCapability),
}
#[derive(Debug, thiserror::Error)]
pub enum RuntimeCapabilityVerificationError {
#[error("Runtime capability verifier mode does not match the verification request")]
ModeMismatch,
#[error(transparent)]
LegacyServer(#[from] RuntimeAuthError),
#[error(transparent)]
WorkspaceIssuer(#[from] WorkspaceCapabilityVerificationError),
}
impl RuntimeCapabilityVerifier {
pub fn verify(
&self,
token: &str,
verification: RuntimeCapabilityVerification<'_>,
) -> Result<VerifiedRuntimeCapability, RuntimeCapabilityVerificationError> {
match (self, verification) {
(
Self::LegacyServer(config),
RuntimeCapabilityVerification::LegacyServer {
required_permission,
now_seconds,
},
) => crate::auth::verify_capability_token(
config,
token,
required_permission,
now_seconds,
)
.map(VerifiedRuntimeCapability::LegacyServer)
.map_err(Into::into),
(
Self::WorkspaceIssuer(verifier),
RuntimeCapabilityVerification::WorkspaceIssuer(expectation),
) => verifier
.verify(token, &expectation)
.map(VerifiedRuntimeCapability::WorkspaceIssuer)
.map_err(Into::into),
_ => Err(RuntimeCapabilityVerificationError::ModeMismatch),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1762,54 +1700,6 @@ mod tests {
);
}
#[test]
fn verifier_selection_never_falls_back_between_legacy_and_workspace_authority() {
assert_eq!(
WorkspaceCapabilityVerifier::new(
Vec::new(),
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap_err(),
WorkspaceCapabilityVerificationError::TrustAuthorityMissing
);
let (key, bundle) = identity("workspace-1", "WK-1", 1);
let record = WorkspaceIssuerTrustRecord::from_bundle(bundle, 1, 1).unwrap();
let workspace = RuntimeCapabilityVerifier::WorkspaceIssuer(
WorkspaceCapabilityVerifier::new(
vec![record.clone()],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
);
let token = issue_workspace_capability_token(&key, &claims(&record, "typed-mode")).unwrap();
assert!(matches!(
workspace.verify(
&token,
RuntimeCapabilityVerification::LegacyServer {
required_permission: None,
now_seconds: 1_001,
},
),
Err(RuntimeCapabilityVerificationError::ModeMismatch)
));
assert!(matches!(
RuntimeCapabilityVerifier::WorkspaceIssuer(
WorkspaceCapabilityVerifier::new(
vec![record],
Arc::new(InMemoryWorkspaceClaimReplayProtection::default()),
)
.unwrap(),
)
.verify(
&token,
RuntimeCapabilityVerification::WorkspaceIssuer(expectation(
&workspace_request_body_digest(br#"{"content":"hello"}"#),
)),
),
Ok(VerifiedRuntimeCapability::WorkspaceIssuer(_))
));
}
#[test]
fn file_replay_protection_survives_reconstruction() {
let temporary = tempfile::tempdir().unwrap();
-29
View File
@@ -1589,14 +1589,6 @@ pub enum WorkspaceRuntimeBindingState {
Revoked,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceRuntimeAuthenticationMode {
LegacyServerIssuer,
WorkspaceIdentity,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
@@ -1642,7 +1634,6 @@ pub struct RuntimeVerificationEvidenceSummary {
pub struct WorkspaceRuntimeBindingSummary {
pub state: WorkspaceRuntimeBindingState,
pub connection_state: RuntimeConnectionDisplayState,
pub authentication_mode: WorkspaceRuntimeAuthenticationMode,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -1747,16 +1738,6 @@ pub struct RuntimeTrustKeyRevealResponse {
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)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -3026,7 +3007,6 @@ pub fn catalog_typescript() -> String {
RuntimeConnectionDisplayState::decl(&config),
RuntimeVerificationOutcome::decl(&config),
RuntimeVerificationEvidenceSummary::decl(&config),
WorkspaceRuntimeAuthenticationMode::decl(&config),
WorkspaceRuntimeBindingSummary::decl(&config),
RuntimeManagementSummary::decl(&config),
WorkspaceRuntimeResource::decl(&config),
@@ -3036,7 +3016,6 @@ pub fn catalog_typescript() -> String {
RuntimeTrustAuditEntry::decl(&config),
WorkspaceRuntimeDetail::decl(&config),
RuntimeTrustKeyRevealResponse::decl(&config),
PutRuntimeTrustKeyRequest::decl(&config),
RevokeRuntimeTrustKeyRequest::decl(&config),
RuntimeTrustConflictKind::decl(&config),
RuntimeTrustConflictResponse::decl(&config),
@@ -3869,14 +3848,6 @@ mod tests {
}))
.is_err()
);
assert!(
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
"public_key": "key",
"expected_revision": 1,
"replace": true
}))
.is_err()
);
assert!(
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
"expected_revision": 1,
+3 -101
View File
@@ -29,7 +29,6 @@ use workdir::{
http::{OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirHttpAuthorization},
};
use worker_runtime::RuntimeWorkspaceScope;
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use worker_runtime::catalog::{
ConfigBundleRef, CreateWorkerRequest, ProfileSelector, ProfileSourceArchiveSource,
RepositoryRefObservation, RepositoryRefObservationRequest,
@@ -2927,7 +2926,6 @@ pub struct RemoteRuntimeConfig {
pub display_name: String,
pub base_url: String,
pub bearer_token: Option<String>,
pub auth: Option<RemoteRuntimeAuthConfig>,
pub workspace_authorization: Option<WorkspaceRuntimeAuthorization>,
pub strict_public_egress: bool,
pub cached_worker_creation_available: bool,
@@ -3099,12 +3097,6 @@ impl WorkspaceRuntimeAuthorization {
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RemoteRuntimeAuthConfig {
pub server_id: String,
pub server_private_key: String,
}
impl std::fmt::Debug for RemoteRuntimeConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RemoteRuntimeConfig")
@@ -3115,7 +3107,6 @@ impl std::fmt::Debug for RemoteRuntimeConfig {
"bearer_token",
&self.bearer_token.as_ref().map(|_| "<redacted>"),
)
.field("auth", &self.auth.as_ref().map(|_| "<capability-signer>"))
.field("strict_public_egress", &self.strict_public_egress)
.field(
"cached_worker_creation_available",
@@ -3142,7 +3133,6 @@ impl RemoteRuntimeConfig {
display_name: display_name.into(),
base_url: base_url.into(),
bearer_token,
auth: None,
workspace_authorization: None,
strict_public_egress: false,
cached_worker_creation_available: false,
@@ -3158,11 +3148,6 @@ impl RemoteRuntimeConfig {
self
}
pub fn with_auth(mut self, auth: RemoteRuntimeAuthConfig) -> Self {
self.auth = Some(auth);
self
}
pub fn with_strict_public_egress(mut self, strict: bool) -> Self {
self.strict_public_egress = strict;
self
@@ -3181,9 +3166,6 @@ impl RemoteRuntimeConfig {
#[derive(Clone)]
struct RemoteWorkdirAuthorization {
runtime_id: String,
workspace_id: String,
auth: Option<RemoteRuntimeAuthConfig>,
workspace_authorization: Option<WorkspaceRuntimeAuthorization>,
fallback_bearer_token: Option<String>,
}
@@ -3192,9 +3174,6 @@ impl std::fmt::Debug for RemoteWorkdirAuthorization {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RemoteWorkdirAuthorization")
.field("runtime_id", &self.runtime_id)
.field("workspace_id", &self.workspace_id)
.field("auth", &self.auth.as_ref().map(|_| "capability_token"))
.field(
"fallback_bearer_token",
&self.fallback_bearer_token.as_ref().map(|_| "configured"),
@@ -3221,19 +3200,6 @@ impl WorkdirHttpAuthorization for RemoteWorkdirAuthorization {
)
.map_err(|error| WorkdirError::Unavailable(error.message));
}
if let Some(auth) = self.auth.as_ref() {
let claims = capability_claims(
&auth.server_id,
&self.runtime_id,
&self.workspace_id,
all_remote_runtime_permissions(),
300,
)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
return CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key)
.sign(&claims)
.map_err(|error| WorkdirError::Unavailable(error.to_string()));
}
self.fallback_bearer_token.clone().ok_or_else(|| {
WorkdirError::Unavailable(
"remote Runtime does not have bearer authorization configured".to_string(),
@@ -3350,7 +3316,6 @@ pub struct RemoteWorkerRuntime {
base_url: String,
workspace_id: String,
bearer_token: Option<String>,
auth: Option<RemoteRuntimeAuthConfig>,
workspace_authorization: Option<WorkspaceRuntimeAuthorization>,
cached_worker_creation_available: bool,
cached_os: String,
@@ -3467,22 +3432,6 @@ fn worker_id_from_remote_path(path_and_query: &str) -> Option<String> {
(!worker_id.is_empty()).then(|| worker_id.to_string())
}
fn all_remote_runtime_permissions() -> Vec<String> {
[
"workers:list",
"workers:create",
"workers:read",
"workers:delete",
"workers:input",
"workers:stop",
"workers:protocol",
"workdirs:operate",
]
.into_iter()
.map(str::to_string)
.collect()
}
impl RemoteWorkerRuntime {
pub fn new(
config: RemoteRuntimeConfig,
@@ -3546,7 +3495,6 @@ impl RemoteWorkerRuntime {
base_url,
workspace_id,
bearer_token: config.bearer_token,
auth: config.auth,
workspace_authorization: config.workspace_authorization,
cached_worker_creation_available: config.cached_worker_creation_available,
cached_os: config.cached_os,
@@ -3573,9 +3521,6 @@ impl RemoteWorkerRuntime {
let workdir_id = Workdir::new(working_directory_id).id().clone();
let authorization: Arc<dyn WorkdirHttpAuthorization> =
Arc::new(RemoteWorkdirAuthorization {
runtime_id: self.runtime_id.clone(),
workspace_id: self.workspace_id.clone(),
auth: self.auth.clone(),
workspace_authorization: self.workspace_authorization.clone(),
fallback_bearer_token: self.bearer_token.clone(),
});
@@ -3708,44 +3653,6 @@ impl RemoteWorkerRuntime {
self.send_json(path, "DELETE", &[], self.http.delete(self.endpoint(path)))
}
fn runtime_capability_token_with_permissions(
&self,
path: &str,
permissions: Vec<String>,
) -> Option<String> {
let auth = self.auth.as_ref()?;
let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key);
let claims = capability_claims(
&auth.server_id,
&self.runtime_id,
&self.workspace_id,
permissions,
300,
)
.map_err(|error| {
eprintln!(
"failed to build Runtime capability claims for {} {}: {error}",
self.runtime_id, path
);
error
})
.ok()?;
signer
.sign(&claims)
.map_err(|error| {
eprintln!(
"failed to sign Runtime capability token for {} {}: {error}",
self.runtime_id, path
);
error
})
.ok()
}
fn runtime_capability_token(&self, path: &str) -> Option<String> {
self.runtime_capability_token_with_permissions(path, all_remote_runtime_permissions())
}
fn ping_http(&self) -> Result<RuntimeHttpPingResponse, RuntimePingFailure> {
const PATH: &str = "/v1/ping";
let workspace_id = self.workspace_id.clone();
@@ -3762,10 +3669,7 @@ impl RemoteWorkerRuntime {
)
})?,
),
None => self.runtime_capability_token_with_permissions(
PATH,
vec![RUNTIME_PING_PERMISSION.to_string()],
),
None => None,
};
let request = self
.http
@@ -3852,7 +3756,7 @@ impl RemoteWorkerRuntime {
worker_id_from_remote_path(path).as_deref(),
body,
)?),
None => self.runtime_capability_token(path),
None => None,
};
run_blocking_http(move || {
let request = request
@@ -4560,9 +4464,7 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
Some(authorization) => authorization
.issue("GET", &path, "workers:protocol", Some(worker_id), &[])
.ok(),
None => self
.runtime_capability_token(&path)
.or_else(|| self.bearer_token.clone()),
None => self.bearer_token.clone(),
};
Some(crate::observation::RuntimeObservationSource::remote_ws(
crate::observation::RuntimeObservationSourceConfig {
+59 -451
View File
@@ -1,14 +1,11 @@
use std::collections::VecDeque;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::hosts::{EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig};
use yoi_workspace_server::store::{
SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
WorkspaceRuntimeBindingState,
@@ -21,8 +18,6 @@ use yoi_workspace_server::{
#[derive(Debug)]
enum Command {
Serve(ServeOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
Migrate(MigrateOptions),
Skills(SkillsCommand),
Help,
@@ -79,8 +74,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? {
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::Skills(command) => run_skills(command),
Command::Help => Ok(()),
@@ -94,8 +87,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
};
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),
"skills" => parse_skills_command(rest),
"serve" => {
@@ -110,375 +101,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help)
}
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,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
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>> {
match command {
SkillsCommand::List(options) => {
@@ -526,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>> {
if options.help {
print_migrate_help();
@@ -655,33 +306,13 @@ fn append_workspace_runtime_sources(
.into_iter()
.flatten()
.collect::<Vec<_>>();
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else {
if !bindings.is_empty() {
return Err(Box::new(CliError(
"Runtime bindings are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
)));
}
return Ok(());
for binding in bindings {
let Some(remote) = remote_runtime_config_from_binding(binding)? else {
continue;
};
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)
.with_strict_public_egress(
runtime.authentication_mode == WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
);
remote_runtime_sources.retain(|existing| {
existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str())
|| existing.runtime_id != runtime.runtime_id
existing.workspace_id.as_deref() != remote.workspace_id.as_deref()
|| existing.runtime_id != remote.runtime_id
});
remote_runtime_sources.push(remote);
}
@@ -851,7 +482,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
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"
);
}
@@ -963,64 +594,41 @@ mod tests {
"unknown serve option `--frontend=/tmp/web`"
);
}
#[test]
fn server_identity_init_requires_explicit_server_id() {
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;
fn runtime_startup_rejects_legacy_server_issuer_bindings() {
let binding = WorkspaceRuntimeBinding {
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-a".to_string(),
display_name: "Runtime A".to_string(),
base_url: "http://127.0.0.1:18080".to_string(),
public_key,
public_key_fingerprint: String::new(),
workspace_id: "workspace-a".to_owned(),
runtime_id: "runtime-a".to_owned(),
display_name: "Runtime A".to_owned(),
base_url: "https://runtime.example.test".to_owned(),
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "2026-07-26T00:00:00Z".to_string(),
updated_at: "2026-07-26T00:00:00Z".to_string(),
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
};
store
.upsert_workspace_runtime_binding(binding.clone(), false)
.unwrap();
assert!(matches!(
store
.upsert_workspace_runtime_binding(binding.clone(), false)
.unwrap(),
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged
));
let mut changed = binding;
changed.base_url = "http://127.0.0.1:18081".to_string();
assert!(
store
.upsert_workspace_runtime_binding(changed.clone(), false)
.is_err()
let error = remote_runtime_config_from_binding(binding)
.unwrap_err()
.to_string();
assert_eq!(
error,
"Runtime binding 'workspace-a:runtime-a' still uses removed legacy Server-issued authentication"
);
store
.upsert_workspace_runtime_binding(changed, true)
.unwrap();
}
#[test]
fn parse_cli_rejects_removed_server_global_runtime_trust_commands() {
for command in ["identity", "trust-runtime"] {
let error = parse_command(&[command.to_owned()]).unwrap_err();
assert_eq!(
error.to_string(),
format!("unknown command `{command}`; expected `migrate`, `skills`, or `serve`")
);
}
}
}
@@ -13,7 +13,6 @@ use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::{client_async_tls_with_config, connect_async};
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use crate::hosts::{RemoteRuntimeConfig, resolve_strict_remote_runtime_endpoint};
@@ -968,7 +967,7 @@ fn runtime_endpoint(base_url: &str) -> String {
}
fn runtime_token(
config: &RemoteRuntimeConfig,
workspace_id: &str,
_workspace_id: &str,
) -> Result<Option<String>, String> {
if let Some(authorization) = config.workspace_authorization.as_ref() {
return authorization
@@ -976,22 +975,7 @@ fn runtime_token(
.map(Some)
.map_err(|error| error.message);
}
let Some(auth) = config.auth.as_ref() else {
return Ok(config.bearer_token.clone());
};
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_err(|error| error.to_string())
Ok(config.bearer_token.clone())
}
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
+49 -310
View File
@@ -82,9 +82,9 @@ use workspace_api::{
PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest,
PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
PutRuntimeTrustKeyRequest, RepositoryAccessProjection, RepositoryDetailResponse,
RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest,
RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse,
RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor,
RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest,
RuntimeConnectionDisplayState, RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse,
RuntimeConnectionTestStatus, RuntimeManagementSummary, RuntimeTrustAuditAction,
RuntimeTrustAuditEntry, RuntimeTrustConflictKind, RuntimeTrustConflictResponse,
@@ -103,9 +103,9 @@ use workspace_api::{
WorkspaceDeletionPreflightResponse, WorkspaceDeletionRequest, WorkspaceDeletionState,
WorkspaceExtensionPointState, WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse,
WorkspaceMetadataSettingsResponse, WorkspacePermissionSummary, WorkspacePublicIdentityBundle,
WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeAuthenticationMode,
WorkspaceRuntimeBindingState, WorkspaceRuntimeBindingSummary, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource, WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse,
WorkspaceRepositoryRecord, WorkspaceResponse, WorkspaceRuntimeBindingState,
WorkspaceRuntimeBindingSummary, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
WorkspaceSigningIdentityPublic, WorkspaceSigningIdentityResponse,
WorkspaceSigningIdentityState, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
};
@@ -3375,9 +3375,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
get(scoped_reveal_runtime_trust_key)
.put(scoped_put_runtime_trust_key)
.delete(scoped_revoke_runtime_trust_key),
get(scoped_reveal_runtime_trust_key).delete(scoped_revoke_runtime_trust_key),
)
.route(
"/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests",
@@ -11828,7 +11826,7 @@ async fn scoped_reveal_runtime_trust_key(
if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
return Err(settings_bad_request(
"embedded_runtime_trust_managed_internally",
"the embedded Runtime trust key is managed by Server identity authority",
"the embedded Runtime trust key is managed by the embedded Runtime authority",
));
}
let binding = api
@@ -11843,144 +11841,6 @@ async fn scoped_reveal_runtime_trust_key(
}))
}
async fn scoped_put_runtime_trust_key(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>,
Extension(actor): Extension<RequestActor>,
Json(request): Json<PutRuntimeTrustKeyRequest>,
) -> std::result::Result<Response, ApiError> {
validate_workspace_scope(&api, &path.workspace_id)?;
require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime trust changes").await?;
let actor_account_id = actor.account_id.clone();
if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
return Err(settings_bad_request(
"embedded_runtime_trust_managed_internally",
"the embedded Runtime trust key is managed by Server identity authority",
));
}
if request.expected_revision == Some(0) {
return Err(settings_bad_request(
"invalid_runtime_binding_revision",
"expected_revision must be greater than zero when provided",
));
}
if request.public_key.len() > 16 * 1024 {
return Err(settings_bad_request(
"runtime_public_key_too_large",
"public_key must be at most 16384 bytes",
));
}
let existing = api
.store
.get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id)
.await?;
if existing.as_ref().is_some_and(|binding| {
binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity
}) {
return Err(settings_bad_request(
"workspace_identity_runtime_key_managed_by_binding",
"replace a Workspace identity Runtime public bundle through the Runtime registration operation with expected_revision",
));
}
let source = api
.config
.remote_runtime_sources
.iter()
.find(|source| {
source.runtime_id == path.runtime_id
&& source.workspace_id.as_deref() == Some(path.workspace_id.as_str())
})
.cloned();
if let (Some(binding), Some(source)) = (&existing, &source)
&& binding.base_url != source.base_url
{
return Err(settings_bad_request(
"runtime_endpoint_mismatch",
"the persisted Runtime endpoint no longer matches Server Runtime configuration; reconcile the endpoint before changing trust",
));
}
let (display_name, base_url) = if let Some(binding) = &existing {
(binding.display_name.clone(), binding.base_url.clone())
} else if let Some(source) = &source {
(source.display_name.clone(), source.base_url.clone())
} else {
return Err(Error::UnknownRuntime(path.runtime_id.clone()).into());
};
let now = Utc::now().to_rfc3339();
let record = WorkspaceRuntimeBinding {
workspace_id: path.workspace_id.clone(),
runtime_id: path.runtime_id.clone(),
display_name,
base_url,
public_key: request.public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: existing.as_ref().map_or(
StoredRuntimeBindingState::Verified,
|binding| match binding.authentication_mode {
StoredRuntimeAuthenticationMode::LegacyServerIssuer => {
StoredRuntimeBindingState::Verified
}
StoredRuntimeAuthenticationMode::WorkspaceIdentity => {
StoredRuntimeBindingState::Configured
}
},
),
authentication_mode: existing.as_ref().map_or(
StoredRuntimeAuthenticationMode::LegacyServerIssuer,
|binding| binding.authentication_mode,
),
workspace_key_id: existing
.as_ref()
.and_then(|binding| binding.workspace_key_id.clone()),
workspace_key_generation: existing
.as_ref()
.and_then(|binding| binding.workspace_key_generation),
created_at: existing
.as_ref()
.map_or_else(|| now.clone(), |binding| binding.created_at.clone()),
updated_at: now,
revoked_at: None,
};
let mutation = api
.store
.put_workspace_runtime_binding_key(record, request.expected_revision, &actor_account_id)
.await;
let (_, binding) = match mutation {
Ok(result) => result,
Err(error) => {
if let Some(response) = runtime_trust_conflict_response(&api, &path, &error).await {
return Ok(response);
}
return Err(error.into());
}
};
{
let mut expectations = api
.runtime_binding_expectations
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if binding.state == StoredRuntimeBindingState::Verified {
expectations.insert(
(path.workspace_id.clone(), path.runtime_id.clone()),
binding.clone(),
);
} else {
expectations.remove(&(path.workspace_id.clone(), path.runtime_id.clone()));
}
}
if binding.state == StoredRuntimeBindingState::Verified
&& let Some(source) = source
{
api.runtime_subscription_broker
.register_remote_runtime(source);
}
Ok(
Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?)
.into_response(),
)
}
async fn scoped_revoke_runtime_trust_key(
State(api): State<WorkspaceApi>,
AxumPath(path): AxumPath<ScopedRuntimePath>,
@@ -11993,7 +11853,7 @@ async fn scoped_revoke_runtime_trust_key(
if path.runtime_id == EMBEDDED_WORKER_RUNTIME_ID {
return Err(settings_bad_request(
"embedded_runtime_trust_managed_internally",
"the embedded Runtime trust key is managed by Server identity authority",
"the embedded Runtime trust key is managed by the embedded Runtime authority",
));
}
if request.expected_revision == 0 {
@@ -16284,14 +16144,6 @@ fn runtime_binding_summary(
StoredRuntimeBindingState::Revoked => WorkspaceRuntimeBindingState::Revoked,
},
connection_state,
authentication_mode: match binding.authentication_mode {
StoredRuntimeAuthenticationMode::LegacyServerIssuer => {
WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer
}
StoredRuntimeAuthenticationMode::WorkspaceIdentity => {
WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
}
},
revision: binding.binding_revision,
workspace_key_id: binding.workspace_key_id.clone(),
workspace_key_generation: binding.workspace_key_generation,
@@ -18234,8 +18086,8 @@ mod tests {
use worker_runtime::working_directory::WorkingDirectoryMaterializer;
use crate::hosts::{
RemoteRuntimeAuthConfig, TicketWorkerRole, WorkerInputKind, WorkerOperationState,
WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent,
TicketWorkerRole, WorkerInputKind, WorkerOperationState, WorkerSpawnAcceptanceRequirement,
WorkerSpawnIntent,
};
use crate::store::{
AccountRecord, ApiTokenRecord, BrowserSessionRecord, MemoryDocumentRecord,
@@ -18691,16 +18543,13 @@ mod tests {
identity: &worker_runtime::auth::RuntimeIdentityMaterial,
runtime_id: &str,
) {
api.config.backend_base_url = Some("server-test".to_owned());
api.config.remote_runtime_sources.push(RemoteRuntimeConfig {
runtime_id: runtime_id.to_owned(),
workspace_id: Some(api.workspace_id().to_owned()),
display_name: runtime_id.to_owned(),
base_url: "https://runtime.test".to_owned(),
bearer_token: None,
auth: Some(RemoteRuntimeAuthConfig {
server_id: "server-test".to_owned(),
server_private_key: "unused".to_owned(),
}),
workspace_authorization: None,
strict_public_egress: false,
cached_worker_creation_available: true,
@@ -18721,9 +18570,9 @@ mod tests {
public_key_fingerprint: String::new(),
binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-test".to_owned()),
workspace_key_generation: Some(1),
created_at: "2026-01-01T00:00:00Z".to_owned(),
updated_at: "2026-01-01T00:00:00Z".to_owned(),
revoked_at: None,
@@ -20538,10 +20387,6 @@ mod tests {
assert_eq!(status, StatusCode::CREATED);
let binding = created.management.binding.as_ref().unwrap();
assert_eq!(binding.state, WorkspaceRuntimeBindingState::Configured);
assert_eq!(
binding.authentication_mode,
WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity
);
assert_eq!(binding.revision, 1);
assert!(binding.workspace_key_id.is_some());
assert_eq!(binding.workspace_key_generation, Some(1));
@@ -20564,25 +20409,6 @@ mod tests {
)),
"configured binding must remain fenced by its current binding revision"
);
let replacement_identity = RuntimeIdentityMaterial::generate("configured-runtime").unwrap();
let generic_put = scoped_put_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: api.config.workspace_id.clone(),
runtime_id: "configured-runtime".to_string(),
}),
Extension(actor.clone()),
Json(PutRuntimeTrustKeyRequest {
public_key: replacement_identity.public_key,
expected_revision: Some(1),
}),
)
.await
.unwrap_err();
assert_eq!(
generic_put.into_response().status(),
StatusCode::BAD_REQUEST
);
let Json(configured_test) = scoped_test_runtime_connection(
State(api.clone()),
AxumPath(ScopedRuntimePath {
@@ -24663,42 +24489,41 @@ mod tests {
}
#[tokio::test]
async fn runtime_trust_management_is_owner_only_revisioned_and_redacted() {
async fn workspace_runtime_trust_reveal_and_revoke_are_owner_only() {
let temp = tempfile::tempdir().unwrap();
let api = test_api(temp.path()).await;
let owner_account_id = format!("account-{TEST_WORKSPACE_ID}");
let owner = RequestActor {
user_id: "owner-user".to_string(),
user_id: "owner-user".to_owned(),
account_id: owner_account_id.clone(),
handle: "owner".to_string(),
display_name: "Owner".to_string(),
handle: "owner".to_owned(),
display_name: "Owner".to_owned(),
auth_method: ActorAuthMethod::BrowserSession,
};
let non_owner = RequestActor {
user_id: "other-user".to_string(),
account_id: "other-account".to_string(),
handle: "other".to_string(),
display_name: "Other".to_string(),
user_id: "other-user".to_owned(),
account_id: "other-account".to_owned(),
handle: "other".to_owned(),
display_name: "Other".to_owned(),
auth_method: ActorAuthMethod::ApiToken,
};
let first = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let second = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let third = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let identity =
worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-a").unwrap();
let now = Utc::now().to_rfc3339();
api.store
.put_workspace_runtime_binding_key(
WorkspaceRuntimeBinding {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
display_name: "Runtime A".to_string(),
base_url: "https://runtime.example".to_string(),
public_key: first.public_key,
workspace_id: TEST_WORKSPACE_ID.to_owned(),
runtime_id: "runtime-a".to_owned(),
display_name: "Runtime A".to_owned(),
base_url: "https://runtime.example".to_owned(),
public_key: identity.public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
state: StoredRuntimeBindingState::Verified,
authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
authentication_mode: StoredRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-test".to_owned()),
workspace_key_generation: Some(1),
created_at: now.clone(),
updated_at: now,
revoked_at: None,
@@ -24709,147 +24534,65 @@ mod tests {
.await
.unwrap();
let Json(detail) = scoped_get_runtime_detail(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
}),
)
.await
.unwrap();
assert_eq!(detail.trust_key.revision, Some(1));
assert!(detail.trust_key.fingerprint.is_some());
let Json(revealed) = scoped_reveal_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
workspace_id: TEST_WORKSPACE_ID.to_owned(),
runtime_id: "runtime-a".to_owned(),
}),
Extension(owner.clone()),
)
.await
.unwrap();
assert!(revealed.public_key.starts_with("yoi-ed25519-pub:v1:"));
let denied_reveal = scoped_reveal_runtime_trust_key(
let denied = scoped_reveal_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
workspace_id: TEST_WORKSPACE_ID.to_owned(),
runtime_id: "runtime-a".to_owned(),
}),
Extension(non_owner.clone()),
)
.await
.unwrap_err();
assert_eq!(
denied_reveal.into_response().status(),
StatusCode::FORBIDDEN
);
let response = scoped_put_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
}),
Extension(owner.clone()),
Json(PutRuntimeTrustKeyRequest {
public_key: second.public_key,
expected_revision: Some(1),
}),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
let detail: WorkspaceRuntimeDetail = serde_json::from_slice(&body).unwrap();
assert_eq!(detail.trust_key.revision, Some(2));
assert_eq!(
detail.recent_audit[0].action,
RuntimeTrustAuditAction::Replaced
);
let stale = scoped_put_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
}),
Extension(owner.clone()),
Json(PutRuntimeTrustKeyRequest {
public_key: third.public_key,
expected_revision: Some(1),
}),
)
.await
.unwrap();
assert_eq!(stale.status(), StatusCode::CONFLICT);
let body = axum::body::to_bytes(stale.into_body(), usize::MAX)
.await
.unwrap();
let conflict: RuntimeTrustConflictResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(conflict.error, RuntimeTrustConflictKind::StaleRevision);
assert_eq!(conflict.current_revision, Some(2));
assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN);
let denied = scoped_revoke_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
workspace_id: TEST_WORKSPACE_ID.to_owned(),
runtime_id: "runtime-a".to_owned(),
}),
Extension(non_owner),
Json(RevokeRuntimeTrustKeyRequest {
expected_revision: 2,
expected_revision: 1,
}),
)
.await
.unwrap_err();
assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN);
let revoked = scoped_revoke_runtime_trust_key(
let response = scoped_revoke_runtime_trust_key(
State(api.clone()),
AxumPath(ScopedRuntimePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
runtime_id: "runtime-a".to_string(),
workspace_id: TEST_WORKSPACE_ID.to_owned(),
runtime_id: "runtime-a".to_owned(),
}),
Extension(owner),
Json(RevokeRuntimeTrustKeyRequest {
expected_revision: 2,
expected_revision: 1,
}),
)
.await
.unwrap();
assert_eq!(revoked.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::OK);
let binding = api
.store
.get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-a")
.await
.unwrap()
.unwrap();
assert_eq!(binding.binding_revision, 3);
assert_eq!(binding.binding_revision, 2);
assert!(binding.revoked_at.is_some());
let listed = workspace_runtime_resources_response(&api, TEST_WORKSPACE_ID)
.await
.unwrap();
let listed_runtime = listed
.items
.iter()
.find(|resource| resource.runtime.runtime_id == "runtime-a")
.expect("revoked binding must remain listed");
assert!(listed_runtime.management.config_managed);
let detail = workspace_runtime_detail(&api, TEST_WORKSPACE_ID, "runtime-a")
.await
.unwrap();
assert_eq!(detail.trust_key.status, RuntimeTrustKeyStatus::Revoked);
assert!(detail.runtime.management.config_managed);
assert!(
!api.runtime_binding_expectations
.read()
.unwrap()
.contains_key(&(TEST_WORKSPACE_ID.to_string(), "runtime-a".to_string()))
);
}
#[tokio::test]
@@ -25350,16 +25093,13 @@ mod tests {
let temp = tempfile::tempdir().unwrap();
let identity = RuntimeIdentityMaterial::generate("runtime-remote").unwrap();
let mut config = test_server_config(temp.path());
config.backend_base_url = Some("server-main".to_owned());
config.remote_runtime_sources.push(RemoteRuntimeConfig {
runtime_id: "runtime-remote".to_string(),
workspace_id: Some(TEST_WORKSPACE_ID.to_string()),
display_name: "Remote Runtime".to_string(),
base_url: "https://runtime.invalid".to_string(),
bearer_token: None,
auth: Some(RemoteRuntimeAuthConfig {
server_id: "server-main".to_string(),
server_private_key: identity.private_key.clone(),
}),
workspace_authorization: None,
strict_public_egress: false,
cached_worker_creation_available: true,
@@ -26887,7 +26627,6 @@ mod tests {
display_name: "Probe Runtime".to_string(),
base_url: endpoint,
bearer_token: Some("test-connection-token".to_string()),
auth: None,
workspace_authorization: None,
strict_public_egress: false,
cached_worker_creation_available: true,
+13 -15
View File
@@ -10,7 +10,6 @@ use worker_runtime::auth::{
};
use worker_runtime::worker_source::InProcessWorkerMutationProof;
use crate::hosts::RemoteRuntimeConfig;
use crate::server::{ServerConfig, WorkspaceApi};
use crate::store::ControlPlaneStore;
@@ -55,7 +54,7 @@ pub async fn verify_runtime_request_source_proof_with_store(
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
let unverified = decode_runtime_request_source_claims(proof)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, workspace_id)?;
let audience = remote_audience(config, workspace_id)?;
let trusted = store
.get_workspace_runtime_binding(workspace_id, &unverified.iss)
.await
@@ -201,7 +200,7 @@ async fn verify_worker_remove_source_with(
PresentedWorkerMutationSourceProof::Remote(token) => {
let unverified = decode_worker_mutation_source_claims(token)
.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
.get_workspace_runtime_binding(&config.workspace_id, &unverified.iss)
.await
@@ -357,20 +356,19 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
}
fn remote_audience<'a>(
config: &'a crate::server::ServerConfig,
runtime_id: &str,
config: &'a ServerConfig,
workspace_id: &str,
) -> Result<std::borrow::Cow<'a, str>, WorkerMutationSourceProofError> {
if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID {
return Ok(std::borrow::Cow::Owned(format!("embedded:{workspace_id}")));
}
) -> Result<&'a str, WorkerMutationSourceProofError> {
config
.remote_runtime_sources
.iter()
.find(|runtime| runtime.runtime_id == runtime_id)
.and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref())
.map(|auth| std::borrow::Cow::Borrowed(auth.server_id.as_str()))
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)
.backend_base_url
.as_deref()
.map(str::trim)
.filter(|audience| !audience.is_empty())
.ok_or_else(|| {
WorkerMutationSourceProofError::Authority(format!(
"Backend public URL is unavailable for Workspace `{workspace_id}` source proof verification"
))
})
}
fn validate_in_process_claims(
+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.
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 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
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:
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:
1. Build the production entrypoints:
`cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
2. Run the focused and dependent tests for the changed contracts, followed by
`cargo fmt --all -- --check` and `git diff --check HEAD`.
3. Run `scripts/isolated-startup-smoke.sh` from an external shell/process.
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.
1. Build the production entrypoints: `cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
2. Run the focused and dependent tests for the changed contracts, followed by workspace-root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`.
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.
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.
5. Verify post-restart readiness through the Workspace Runtime projection, ping, Worker list/create, protocol subscription, and a Runtime-to-Server source-proof operation before treating the environment as healthy.
The smoke harness runs the normal `yoi-server` and `yoi-runtime` binaries using
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.
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.
## What to record
@@ -45,6 +25,8 @@ A report is useful when it explains:
- what design boundary was missing
- 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
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.
+52 -291
View File
@@ -1,315 +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.
- 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.
旧 Server identity/trust 管理 command と旧 Runtime-side Server trust command、旧 Runtime auth key flags は廃止済みである。これらに相当する Server-global trust を fallback として使ってはならない。
## Identifiers used in examples
## Provisioning
Replace these values for the deployment:
1. Runtime identity を初期化する。
```text
SERVER_ID=server-main
RUNTIME_ID=runtime-main
RUNTIME_BASE_URL=http://127.0.0.1:38800
```
```sh
yoi-runtime identity init --runtime-id <runtime-id>
yoi-runtime identity show
```
`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>
yoi-runtime trust-workspace show --workspace-id <workspace-id>
```
From the Workspace Server host:
5. Runtime が challenge proof を生成し、Workspace owner が Server に submit する。
6. Server が verified binding を commit した後、通常の Workspace-signed request が利用可能になる。
```bash
yoi-server identity init --server-id server-main
```
同じ Runtime identity は異なる Workspace から独立して信頼できる。trust record、replay protection、binding、失効はすべて Workspace scope で評価する。
Show the public identity and copy the `public_key` value:
## Runtime auth file
```bash
yoi-server identity show --json
```
`runtime-auth.toml` は Runtime identity と Workspace issuer records のみを authority とする。
旧 Server trust entry は読み飛ばされ、以後の identity / `trust-workspace` 更新時に書き戻されない。旧 entry を残しても認証には使用されない。
The Server private identity is stored in the Yoi data directory under the Server data root, currently:
`trust-workspace` の file store は次を fail closed で検証する。
```text
<data_dir>/server/identity.toml
```
- 最大 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
On Unix this file is written with `0600` permissions. Do not copy the private key to Runtime or commit it to the repository.
## Local token
## 2. Create and show the Runtime identity
`--local-token` は明示的な local Runtime 呼び出し専用であり、Remote Workspace binding の代替ではない。Workspace issuer auth が有効な Remote Runtime request は Workspace capability token を使う。
From the Runtime host, using the same Runtime storage flags that the Runtime server process will use:
## Rotation と失効
```bash
yoi-runtime identity init --runtime-id runtime-main
```
Workspace signing key または Runtime key の変更は、現在 binding を置き換える明示的な provisioning 操作として行う。古い generation、古い Runtime key、revoked binding、失効済み token、replayed JTI は即時拒否する。
Show the public identity and copy the `public_key` value:
Server の Runtime cache は現在の persisted binding 全体と照合する。endpoint、Runtime public key/fingerprint、binding revision、Workspace key generation の変更を検知した場合、stale client を利用しない。
```bash
yoi-runtime identity show --json
```
## 運用確認
By default, Runtime auth state is stored at:
Remote Runtime を有効化した後は次を確認する。
```text
<data_dir>/runtime/auth.toml
```
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 だけでは認証されない。
If the Runtime process is launched with `--fs-root` or `--fs-runtime-dir`, pass the same flags to every `identity`, `trust-server`, and `trust-workspace` command. Otherwise the setup command may write an auth file that the server process never reads.
Example with explicit Runtime storage:
```bash
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
```
## Workspace issuer trust foundation
Workspace signing identity is Workspace-scoped. Provision the identity through the authenticated owner-only Workspace Settings operation, then export the public bundle from:
```text
GET /api/w/<WORKSPACE_ID>/settings/workspace/signing-identity
```
Save the response's non-null `public_bundle` object—not the outer response wrapper—as `workspace-public-identity.json`, and transfer only that document to the Runtime host:
```bash
yoi-runtime trust-workspace add \
--bundle workspace-public-identity.json \
--fs-root /var/lib/yoi-runtime
```
The bundle contains `workspace_id`, `backend_url`, `key_id`, `algorithm`, `public_key`, `public_key_fingerprint`, and the Workspace signing identity `revision`. It never contains the Workspace private key. Do not transfer the Server-side private-material file or place private material in Runtime configuration.
Inspect the Runtime trust records without exposing private material:
```bash
yoi-runtime trust-workspace list --offset 0 --limit 100 --fs-root /var/lib/yoi-runtime
yoi-runtime trust-workspace show \
--workspace-id '<WORKSPACE_ID>' \
--fs-root /var/lib/yoi-runtime
```
`list` returns a bounded page with `offset`, `limit`, `total`, and `records`; advance `--offset` to inspect later pages. The records contain public trust metadata only.
An exact repeated `add` is idempotent. A different bundle for an existing Workspace is rejected; use the explicit `replace` operation after verifying the new public fingerprint out of band:
```bash
yoi-runtime trust-workspace replace \
--bundle workspace-public-identity-v2.json \
--fs-root /var/lib/yoi-runtime
```
Runtime increments a local `trust_generation` on replacement and revocation. Signed claims bind the issuer URL, Workspace signing identity revision, Runtime trust generation, live WorkspaceRuntime `binding_revision`, Runtime/Worker target, operation, request-body SHA-256 digest, expiry, and one-time `jti`. Claims are accepted only when the Workspace identity revision, Runtime trust generation, and caller-supplied current binding revision all match exactly. Revoke trust without deleting its generation fence:
```bash
yoi-runtime trust-workspace revoke \
--workspace-id '<WORKSPACE_ID>' \
--fs-root /var/lib/yoi-runtime
```
For rotation, create and export the new Workspace identity first, verify its fingerprint, replace Runtime trust, update the WorkspaceRuntime binding authority, and only then issue claims under the new key/generation. For emergency revocation, revoke Runtime trust first and stop issuing claims; reactivation requires an explicit `replace` with an active public bundle. Runtime auth state is written atomically with private file permissions and survives restart; malformed trust state fails closed.
This command establishes the Runtime-side trust and claim-verifier foundation only. The Runtime auth store is read during process startup; once signed verification is connected, a controlled Runtime restart will be required before a changed trust record affects verification. Do not restart a live Runtime until its active Worker lifecycle has been handled. Until the signed-verification cutover Ticket is integrated, existing remote control traffic continues to select the legacy trusted-Server verifier explicitly; Runtime never falls back from one verifier mode to the other.
## 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.
Server / Runtime の再起動は live reload ではない authority 変更を反映するときだけ、通常の運用権限と migration gate に従って行う。実行中プロセスを開発 Worker が無断で停止してはならない。
-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"
@@ -373,14 +373,9 @@ export type RuntimeVerificationEvidenceSummary = {
runtime_identity_revision: number;
};
export type WorkspaceRuntimeAuthenticationMode =
| "legacy_server_issuer"
| "workspace_identity";
export type WorkspaceRuntimeBindingSummary = {
state: WorkspaceRuntimeBindingState;
connection_state: RuntimeConnectionDisplayState;
authentication_mode: WorkspaceRuntimeAuthenticationMode;
revision: number;
workspace_key_id?: string | null;
workspace_key_generation?: number | null;
@@ -446,11 +441,6 @@ export type WorkspaceRuntimeDetail = {
export type RuntimeTrustKeyRevealResponse = { public_key: string };
export type PutRuntimeTrustKeyRequest = {
public_key: string;
expected_revision: number | null;
};
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
@@ -1,7 +1,6 @@
import type {
CreateRemoteRuntimeRequest,
Diagnostic,
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeConnectionDisplayState,
RuntimeIdentityAuthority,
@@ -17,7 +16,6 @@ import type {
RuntimeTrustKeyState,
RuntimeTrustKeyStatus,
RuntimeVerificationEvidenceSummary,
WorkspaceRuntimeAuthenticationMode,
WorkspaceRuntimeBindingState,
WorkspaceRuntimeBindingSummary,
WorkspaceRuntimeDetail,
@@ -85,11 +83,6 @@ const CONNECTION_STATES = new Set<RuntimeConnectionDisplayState>([
"revoked",
]);
const AUTHENTICATION_MODES = new Set<WorkspaceRuntimeAuthenticationMode>([
"legacy_server_issuer",
"workspace_identity",
]);
const encoder = new TextEncoder();
type JsonObject = Record<string, unknown>;
@@ -388,15 +381,10 @@ function runtimeBinding(
const item = object(value, path);
exactKeys(
item,
["state", "connection_state", "authentication_mode", "revision"],
["state", "connection_state", "revision"],
["workspace_key_id", "workspace_key_generation", "verification"],
path,
);
const authenticationMode = enumValue(
item.authentication_mode,
`${path}.authentication_mode`,
AUTHENTICATION_MODES,
);
const workspaceKeyId = optionalNullableString(
item.workspace_key_id,
`${path}.workspace_key_id`,
@@ -406,22 +394,13 @@ function runtimeBinding(
item.workspace_key_generation,
`${path}.workspace_key_generation`,
);
const state = enumValue(item.state, `${path}.state`, BINDING_STATES);
if (
authenticationMode === "workspace_identity" &&
state !== "revoked" &&
(workspaceKeyId == null || workspaceKeyGeneration == null)
) {
return fail(path, "requires Workspace signing key identity metadata");
}
if (
authenticationMode === "legacy_server_issuer" &&
(workspaceKeyId != null || workspaceKeyGeneration != null)
) {
return fail(
path,
"must not attach Workspace key metadata to legacy authority",
);
}
const state = enumValue(item.state, `${path}.state`, BINDING_STATES);
const connectionState = enumValue(
item.connection_state,
`${path}.connection_state`,
@@ -437,7 +416,6 @@ function runtimeBinding(
return fail(path, "verification must match the current binding revision");
}
if (
authenticationMode === "workspace_identity" &&
connectionState === "verified" &&
(verification === undefined ||
verification.verified_at === null ||
@@ -451,7 +429,6 @@ function runtimeBinding(
return {
state,
connection_state: connectionState,
authentication_mode: authenticationMode,
revision,
...(workspaceKeyId === undefined
? {}
@@ -988,29 +965,6 @@ export async function previewRuntimePublicKeyFingerprint(
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(
workspaceId: string,
runtimeId: string,
@@ -1,14 +1,12 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import type {
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyStatus,
} from '$lib/generated/workspace-api';
import {
createRemoteRuntime,
previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey,
revealRuntimeTrustKey,
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
@@ -145,18 +143,14 @@
}
}
const request: PutRuntimeTrustKeyRequest = {
public_key: key,
expected_revision: trust.revision ?? null,
};
const operation = routeFence.capture(data.runtimeId);
busyAction = 'save';
try {
const binding = data.runtimeDetail.runtime.management.binding;
if (binding?.authentication_mode === 'workspace_identity') {
if (!data.runtimeDetail.endpoint) {
throw new RuntimeTrustRequestError('The authoritative Runtime endpoint is unavailable.');
if (!binding || !data.runtimeDetail.endpoint) {
throw new RuntimeTrustRequestError(
'The Workspace identity binding and authoritative Runtime endpoint are required.',
);
}
await createRemoteRuntime(data.workspaceId, {
public_bundle: {
@@ -167,9 +161,6 @@
endpoint: data.runtimeDetail.endpoint,
expected_revision: binding.revision,
});
} else {
await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request);
}
if (!isCurrentRoute(operation)) return;
publicKey = '';
fingerprintConfirmation = '';
@@ -333,7 +324,6 @@
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
<div><dt>Connection state</dt><dd>{runtime.management.binding?.connection_state ?? 'Not configured'}</dd></div>
<div><dt>Authentication mode</dt><dd>{runtime.management.binding?.authentication_mode ?? '—'}</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>
@@ -9,7 +9,6 @@ import {
parseWorkspaceRuntimeDetail,
parseWorkspaceRuntimeList,
previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey,
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
RuntimeTrustRouteFence,
@@ -43,7 +42,6 @@ function runtime() {
binding: {
state: "verified",
connection_state: "verified",
authentication_mode: "workspace_identity",
revision: 3,
workspace_key_id: "WK-1",
workspace_key_generation: 1,
@@ -279,52 +277,6 @@ Deno.test("Runtime public key preview matches the Server fingerprint contract",
);
});
Deno.test("typed trust conflict is validated and preserves authoritative revision", async () => {
let sentBody: unknown = null;
const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => {
sentBody = JSON.parse(String(init?.body)) as unknown;
return Promise.resolve(
new Response(
JSON.stringify({
error: "stale_revision",
message: "Runtime trust changed",
current_revision: 4,
current_fingerprint: "SHA256:new",
}),
{ status: 409, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
try {
await putRuntimeTrustKey(
"workspace-a",
"arcadia",
{ public_key: "ssh-ed25519 AAAA-new", expected_revision: 3 },
fetchImpl,
);
throw new Error("expected mutation to reject");
} catch (error) {
assert(
error instanceof RuntimeTrustConflictError,
"expected typed conflict",
);
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",
);
});
Deno.test("Runtime create surfaces bounded Settings error details", async () => {
const fetchImpl = (() =>
Promise.resolve(