feat: add workspace runtime trust key management
This commit is contained in:
@@ -12,8 +12,10 @@ use workspace_api::{
|
||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse,
|
||||
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest,
|
||||
RevokeRuntimeTrustKeyRequest, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
|
||||
WorkspaceRuntimeResource,
|
||||
};
|
||||
|
||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
||||
@@ -241,6 +243,43 @@ impl BackendWorkspaceProductClient {
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_runtimes(
|
||||
&self,
|
||||
) -> Result<ListResponse<WorkspaceRuntimeResource>, BackendWorkspaceClientError> {
|
||||
self.get_json("/runtimes")
|
||||
}
|
||||
|
||||
pub fn runtime_detail(
|
||||
&self,
|
||||
runtime_id: &str,
|
||||
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||
self.get_json(&format!("/runtimes/{}", encode_path_segment(runtime_id)))
|
||||
}
|
||||
|
||||
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,
|
||||
request: &RevokeRuntimeTrustKeyRequest,
|
||||
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
|
||||
self.send_json(
|
||||
Method::DELETE,
|
||||
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
|
||||
Some(request),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn memory_document(&self) -> Result<MemoryDocumentResponse, BackendWorkspaceClientError> {
|
||||
self.get_json("/memory")
|
||||
}
|
||||
|
||||
@@ -539,6 +539,7 @@ pub enum WorkspaceAuthConfig {
|
||||
pub struct WorkspacePermissionSummary {
|
||||
pub manage_repositories: bool,
|
||||
pub manage_secrets: bool,
|
||||
pub manage_runtimes: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
@@ -1135,6 +1136,7 @@ pub struct ObjectiveLinkTicketRequest {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeSourceKind {
|
||||
EmbeddedWorkerRuntime,
|
||||
@@ -1142,6 +1144,7 @@ pub enum RuntimeSourceKind {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeSourceStatus {
|
||||
Active,
|
||||
@@ -1149,6 +1152,7 @@ pub enum RuntimeSourceStatus {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeIdentityAuthority {
|
||||
RuntimeRegistryProjection,
|
||||
@@ -1156,6 +1160,8 @@ pub enum RuntimeIdentityAuthority {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeSourceSummary {
|
||||
pub kind: RuntimeSourceKind,
|
||||
pub status: RuntimeSourceStatus,
|
||||
@@ -1164,6 +1170,7 @@ pub struct RuntimeSourceSummary {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct RuntimeSummary {
|
||||
pub runtime_id: String,
|
||||
pub label: String,
|
||||
@@ -1180,6 +1187,8 @@ pub struct RuntimeSummary {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeManagementSummary {
|
||||
pub built_in: bool,
|
||||
pub config_managed: bool,
|
||||
@@ -1189,12 +1198,119 @@ pub struct RuntimeManagementSummary {
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
pub struct WorkspaceRuntimeResource {
|
||||
#[serde(flatten)]
|
||||
pub runtime: RuntimeSummary,
|
||||
pub management: RuntimeManagementSummary,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeTrustKeyStatus {
|
||||
Unconfigured,
|
||||
Active,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeTrustKeyState {
|
||||
pub status: RuntimeTrustKeyStatus,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub public_key: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fingerprint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
|
||||
pub revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub updated_at: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub revoked_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeTrustAuditAction {
|
||||
Created,
|
||||
Replaced,
|
||||
Reactivated,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeTrustAuditEntry {
|
||||
pub action: RuntimeTrustAuditAction,
|
||||
pub actor_account_id: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub old_fingerprint: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub new_fingerprint: Option<String>,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub revision: u64,
|
||||
pub at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct WorkspaceRuntimeDetail {
|
||||
pub workspace_id: String,
|
||||
pub runtime: WorkspaceRuntimeResource,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub endpoint: Option<String>,
|
||||
pub trust_key: RuntimeTrustKeyState,
|
||||
#[serde(default)]
|
||||
pub recent_audit: Vec<RuntimeTrustAuditEntry>,
|
||||
}
|
||||
|
||||
#[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)]
|
||||
pub struct RevokeRuntimeTrustKeyRequest {
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub expected_revision: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum RuntimeTrustConflictKind {
|
||||
StaleRevision,
|
||||
FingerprintInUse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct RuntimeTrustConflictResponse {
|
||||
pub error: RuntimeTrustConflictKind,
|
||||
pub message: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(type = "number"))]
|
||||
pub current_revision: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub current_fingerprint: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct CreateRemoteRuntimeRequest {
|
||||
@@ -2394,6 +2510,22 @@ pub fn catalog_typescript() -> String {
|
||||
RepositoryListResponse::decl(&config),
|
||||
RepositoryDetailResponse::decl(&config),
|
||||
RepositoryLogResponse::decl(&config),
|
||||
RuntimeSourceKind::decl(&config),
|
||||
RuntimeSourceStatus::decl(&config),
|
||||
RuntimeIdentityAuthority::decl(&config),
|
||||
RuntimeSourceSummary::decl(&config),
|
||||
RuntimeSummary::decl(&config),
|
||||
RuntimeManagementSummary::decl(&config),
|
||||
WorkspaceRuntimeResource::decl(&config),
|
||||
RuntimeTrustKeyStatus::decl(&config),
|
||||
RuntimeTrustKeyState::decl(&config),
|
||||
RuntimeTrustAuditAction::decl(&config),
|
||||
RuntimeTrustAuditEntry::decl(&config),
|
||||
WorkspaceRuntimeDetail::decl(&config),
|
||||
PutRuntimeTrustKeyRequest::decl(&config),
|
||||
RevokeRuntimeTrustKeyRequest::decl(&config),
|
||||
RuntimeTrustConflictKind::decl(&config),
|
||||
RuntimeTrustConflictResponse::decl(&config),
|
||||
RuntimeConnectionTestStatus::decl(&config),
|
||||
RuntimeConnectionTestFailureKind::decl(&config),
|
||||
RuntimeConnectionTestResponse::decl(&config),
|
||||
@@ -3022,7 +3154,8 @@ mod tests {
|
||||
}},
|
||||
"permissions": {
|
||||
"manage_repositories": true,
|
||||
"manage_secrets": true
|
||||
"manage_secrets": true,
|
||||
"manage_runtimes": true
|
||||
},
|
||||
"extension_points": {
|
||||
"store": "sqlite",
|
||||
@@ -3086,6 +3219,75 @@ mod tests {
|
||||
assert!(serde_json::from_value::<RepositoryListResponse>(stale).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_detail_and_trust_mutations_are_closed_and_typed() {
|
||||
let detail = serde_json::json!({
|
||||
"workspace_id": "workspace-test",
|
||||
"runtime": {
|
||||
"runtime_id": "runtime-test",
|
||||
"label": "Runtime Test",
|
||||
"kind": "remote_http",
|
||||
"status": "active",
|
||||
"source": {
|
||||
"kind": "remote_http",
|
||||
"status": "active",
|
||||
"identity_authority": "runtime_registry_projection",
|
||||
"note": "active"
|
||||
},
|
||||
"host_ids": [],
|
||||
"worker_creation_available": true,
|
||||
"os": "linux",
|
||||
"arch": "x86_64",
|
||||
"diagnostics": [],
|
||||
"management": {
|
||||
"built_in": false,
|
||||
"config_managed": true,
|
||||
"removable": true,
|
||||
"endpoint_configured": true,
|
||||
"token_ref_configured": false
|
||||
}
|
||||
},
|
||||
"endpoint": "https://runtime.example",
|
||||
"trust_key": {
|
||||
"status": "active",
|
||||
"public_key": "ssh-ed25519 AAAA runtime-test",
|
||||
"fingerprint": "SHA256:test",
|
||||
"revision": 2,
|
||||
"created_at": "2026-09-01T12:00:00Z",
|
||||
"updated_at": "2026-09-01T13:00:00Z"
|
||||
},
|
||||
"recent_audit": [{
|
||||
"action": "replaced",
|
||||
"actor_account_id": "account-owner",
|
||||
"old_fingerprint": "SHA256:old",
|
||||
"new_fingerprint": "SHA256:test",
|
||||
"revision": 2,
|
||||
"at": "2026-09-01T13:00:00Z"
|
||||
}]
|
||||
});
|
||||
let parsed: WorkspaceRuntimeDetail = serde_json::from_value(detail.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(parsed).unwrap(), detail);
|
||||
|
||||
let mut unknown = detail;
|
||||
unknown["trust_key"]["private_key"] = serde_json::json!("forbidden");
|
||||
assert!(serde_json::from_value::<WorkspaceRuntimeDetail>(unknown).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,
|
||||
"delete_runtime": true
|
||||
}))
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_connection_test_response_is_closed_and_typed() {
|
||||
let compatible = serde_json::json!({
|
||||
|
||||
@@ -440,6 +440,7 @@ CREATE TABLE workspace_runtime_bindings (
|
||||
base_url TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
public_key_fingerprint TEXT NOT NULL,
|
||||
binding_revision INTEGER NOT NULL CHECK (binding_revision > 0),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
revoked_at TEXT,
|
||||
@@ -447,6 +448,22 @@ CREATE TABLE workspace_runtime_bindings (
|
||||
UNIQUE (workspace_id, public_key_fingerprint),
|
||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE TABLE workspace_runtime_binding_audit (
|
||||
workspace_id TEXT NOT NULL,
|
||||
runtime_id TEXT NOT NULL,
|
||||
actor_account_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL CHECK (action IN ('created', 'replaced', 'reactivated', 'revoked')),
|
||||
old_fingerprint TEXT,
|
||||
new_fingerprint TEXT,
|
||||
binding_revision INTEGER NOT NULL CHECK (binding_revision > 0),
|
||||
at TEXT NOT NULL,
|
||||
PRIMARY KEY (workspace_id, runtime_id, binding_revision),
|
||||
FOREIGN KEY(workspace_id, runtime_id)
|
||||
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE RESTRICT,
|
||||
FOREIGN KEY(actor_account_id) REFERENCES accounts(account_id) ON DELETE RESTRICT
|
||||
);
|
||||
CREATE INDEX idx_workspace_runtime_binding_audit_recent
|
||||
ON workspace_runtime_binding_audit(workspace_id, runtime_id, binding_revision DESC);
|
||||
CREATE TABLE typed_ticket_artifacts (
|
||||
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
|
||||
PRIMARY KEY (workspace_id, ticket_id, relative_path),
|
||||
|
||||
@@ -120,6 +120,15 @@ pub enum Error {
|
||||
WorkspaceConfigConflict(String),
|
||||
#[error("Runtime binding conflict: {0}")]
|
||||
RuntimeBindingConflict(String),
|
||||
#[error("Runtime binding revision conflict: expected {expected:?}, current {actual:?}")]
|
||||
RuntimeBindingRevisionConflict {
|
||||
expected: Option<u64>,
|
||||
actual: Option<u64>,
|
||||
},
|
||||
#[error("Runtime public key fingerprint is already bound in this Workspace: {fingerprint}")]
|
||||
RuntimeBindingFingerprintConflict { fingerprint: String },
|
||||
#[error("Runtime binding was not found for {runtime_id}")]
|
||||
RuntimeBindingNotFound { runtime_id: String },
|
||||
#[error("Repository conflict: {0}")]
|
||||
RepositoryConflict(String),
|
||||
#[error("Registry inconsistency: {0}")]
|
||||
|
||||
@@ -324,6 +324,7 @@ fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error
|
||||
base_url,
|
||||
public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
revoked_at: None,
|
||||
@@ -859,6 +860,7 @@ mod tests {
|
||||
base_url: "http://127.0.0.1:18080".to_string(),
|
||||
public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "2026-07-26T00:00:00Z".to_string(),
|
||||
updated_at: "2026-07-26T00:00:00Z".to_string(),
|
||||
revoked_at: None,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, Weak};
|
||||
use std::sync::{Arc, Mutex, RwLock, Weak};
|
||||
|
||||
use axum::body::Bytes;
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
@@ -74,11 +74,13 @@ use workspace_api::{
|
||||
PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse,
|
||||
PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest,
|
||||
PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest,
|
||||
RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse,
|
||||
RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor,
|
||||
RotateRepositorySshCredentialRequest, RuntimeConnectionTestFailureKind,
|
||||
RuntimeConnectionTestResponse, RuntimeConnectionTestStatus, RuntimeManagementSummary,
|
||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||
PutRuntimeTrustKeyRequest, RepositoryAccessProjection, RepositoryDetailResponse,
|
||||
RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
|
||||
RequestActor, RevokeRuntimeTrustKeyRequest, RotateRepositorySshCredentialRequest,
|
||||
RuntimeConnectionTestFailureKind, RuntimeConnectionTestResponse, RuntimeConnectionTestStatus,
|
||||
RuntimeManagementSummary, RuntimeTrustAuditAction, RuntimeTrustAuditEntry,
|
||||
RuntimeTrustConflictKind, RuntimeTrustConflictResponse, RuntimeTrustKeyState,
|
||||
RuntimeTrustKeyStatus, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||
UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse,
|
||||
WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
|
||||
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
||||
@@ -90,8 +92,8 @@ use workspace_api::{
|
||||
WorkspaceCatalogListResponse, WorkspaceCreateResponse, WorkspaceExtensionPointState,
|
||||
WorkspaceExtensionPoints, WorkspaceMetadataMutationResponse, WorkspaceMetadataSettingsResponse,
|
||||
WorkspacePermissionSummary, WorkspaceRepositoryRecord, WorkspaceResponse,
|
||||
WorkspaceRuntimeResource, WorkspaceSummary, WorkspaceWorkerDiscoveryItem,
|
||||
WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||
WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceSummary,
|
||||
WorkspaceWorkerDiscoveryItem, WorkspaceWorkerDiscoveryPage, WorkspaceWorkerSubject,
|
||||
};
|
||||
|
||||
use crate::auth::{
|
||||
@@ -153,7 +155,7 @@ use crate::store::{
|
||||
RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord,
|
||||
TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord,
|
||||
WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord,
|
||||
WorkspaceResourceKind,
|
||||
WorkspaceResourceKind, WorkspaceRuntimeBinding, WorkspaceRuntimeBindingAuditRecord,
|
||||
};
|
||||
use crate::workdir_removal::{
|
||||
WorkdirRemovalAttemptOwner, WorkdirRemovalDisposition, WorkdirRemovalOperation,
|
||||
@@ -585,6 +587,7 @@ pub struct WorkspaceApi {
|
||||
prompt_projection_cache: crate::prompt_settings::WorkspacePromptProjectionCache,
|
||||
authority: SqliteWorkspaceAuthority,
|
||||
runtime: Arc<RuntimeRegistry>,
|
||||
runtime_binding_expectations: Arc<RwLock<HashMap<(String, String), WorkspaceRuntimeBinding>>>,
|
||||
companion: Arc<CompanionConsole>,
|
||||
orchestrator_spawn_lock: Arc<std::sync::Mutex<()>>,
|
||||
orchestrator_attention_fingerprint: Arc<Mutex<Option<String>>>,
|
||||
@@ -1575,6 +1578,7 @@ impl WorkspaceApi {
|
||||
base_url: "in-process://embedded".to_owned(),
|
||||
public_key: embedded_identity.public_key.clone(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: config.workspace_created_at.clone(),
|
||||
updated_at: config.workspace_created_at.clone(),
|
||||
revoked_at: None,
|
||||
@@ -1614,8 +1618,7 @@ impl WorkspaceApi {
|
||||
.then(|| (source.runtime_id.clone(), source.base_url.clone()))
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let expected_runtime_bindings = Arc::new(
|
||||
store
|
||||
let expected_runtime_bindings = store
|
||||
.list_workspace_runtime_bindings(&config.workspace_id, false)
|
||||
.await?
|
||||
.into_iter()
|
||||
@@ -1623,9 +1626,14 @@ impl WorkspaceApi {
|
||||
.filter(|binding| {
|
||||
configured_runtime_endpoints.get(&binding.runtime_id) == Some(&binding.base_url)
|
||||
})
|
||||
.map(|binding| (binding.runtime_id.clone(), binding))
|
||||
.collect::<HashMap<_, _>>(),
|
||||
);
|
||||
.map(|binding| {
|
||||
(
|
||||
(binding.workspace_id.clone(), binding.runtime_id.clone()),
|
||||
binding,
|
||||
)
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let workspace_id = config.workspace_id.clone();
|
||||
let api = Self::new_with_execution_backend_and_broker(
|
||||
config,
|
||||
store,
|
||||
@@ -1634,15 +1642,34 @@ impl WorkspaceApi {
|
||||
Some(worker_remove_dispatcher),
|
||||
)
|
||||
.await?;
|
||||
*api.runtime_binding_expectations
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = expected_runtime_bindings;
|
||||
let runtime_binding_expectations = Arc::clone(&api.runtime_binding_expectations);
|
||||
api.runtime.set_runtime_binding_gate(move |runtime_id| {
|
||||
expected_runtime_bindings
|
||||
.get(runtime_id)
|
||||
runtime_binding_expectations
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.get(&(workspace_id.clone(), runtime_id.to_string()))
|
||||
.is_some_and(|expected| {
|
||||
runtime_binding_store
|
||||
.workspace_runtime_binding_matches(expected)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
});
|
||||
let active_expectations = api
|
||||
.runtime_binding_expectations
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
for source in &api.config.remote_runtime_sources {
|
||||
if !active_expectations
|
||||
.contains_key(&(api.config.workspace_id.clone(), source.runtime_id.clone()))
|
||||
{
|
||||
api.runtime_subscription_broker
|
||||
.unregister_runtime(&source.runtime_id);
|
||||
}
|
||||
}
|
||||
drop(active_expectations);
|
||||
Ok(api)
|
||||
}
|
||||
|
||||
@@ -1748,6 +1775,7 @@ impl WorkspaceApi {
|
||||
config,
|
||||
store,
|
||||
runtime,
|
||||
runtime_binding_expectations: Arc::new(RwLock::new(HashMap::new())),
|
||||
companion,
|
||||
orchestrator_spawn_lock: Arc::new(std::sync::Mutex::new(())),
|
||||
orchestrator_attention_fingerprint: Arc::new(Mutex::new(None)),
|
||||
@@ -2648,7 +2676,11 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}",
|
||||
delete(scoped_delete_remote_runtime),
|
||||
get(scoped_get_runtime_detail).delete(scoped_delete_remote_runtime),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/trust-key",
|
||||
put(scoped_put_runtime_trust_key).delete(scoped_revoke_runtime_trust_key),
|
||||
)
|
||||
.route(
|
||||
"/api/w/{workspace_id}/runtimes/{runtime_id}/connection-tests",
|
||||
@@ -10956,11 +10988,225 @@ async fn scoped_create_remote_runtime(
|
||||
create_remote_runtime(State(api), Json(request)).await
|
||||
}
|
||||
|
||||
async fn scoped_get_runtime_detail(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
) -> ApiResult<Json<WorkspaceRuntimeDetail>> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
let workspace = api
|
||||
.store
|
||||
.get_workspace(&path.workspace_id)
|
||||
.await?
|
||||
.ok_or(Error::WorkspaceIdMismatch)?;
|
||||
let is_owner = workspace.owner_account_id == actor.account_id;
|
||||
Ok(Json(
|
||||
workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, is_owner).await?,
|
||||
))
|
||||
}
|
||||
|
||||
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?;
|
||||
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,
|
||||
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());
|
||||
}
|
||||
};
|
||||
api.runtime_binding_expectations
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.insert(
|
||||
(path.workspace_id.clone(), path.runtime_id.clone()),
|
||||
binding,
|
||||
);
|
||||
if let Some(source) = source {
|
||||
api.runtime_subscription_broker
|
||||
.register_remote_runtime(source);
|
||||
}
|
||||
Ok(
|
||||
Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn scoped_revoke_runtime_trust_key(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
Json(request): Json<RevokeRuntimeTrustKeyRequest>,
|
||||
) -> 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 == 0 {
|
||||
return Err(settings_bad_request(
|
||||
"invalid_runtime_binding_revision",
|
||||
"expected_revision must be greater than zero",
|
||||
));
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
let mutation = api
|
||||
.store
|
||||
.revoke_workspace_runtime_binding_key(
|
||||
&path.workspace_id,
|
||||
&path.runtime_id,
|
||||
request.expected_revision,
|
||||
&actor_account_id,
|
||||
&now,
|
||||
)
|
||||
.await;
|
||||
let _ = 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());
|
||||
}
|
||||
};
|
||||
api.runtime_binding_expectations
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.remove(&(path.workspace_id.clone(), path.runtime_id.clone()));
|
||||
api.runtime_subscription_broker
|
||||
.unregister_runtime(&path.runtime_id);
|
||||
Ok(
|
||||
Json(workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id, true).await?)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn runtime_trust_conflict_response(
|
||||
api: &WorkspaceApi,
|
||||
path: &ScopedRuntimePath,
|
||||
error: &Error,
|
||||
) -> Option<Response> {
|
||||
let kind = match error {
|
||||
Error::RuntimeBindingRevisionConflict { .. } => RuntimeTrustConflictKind::StaleRevision,
|
||||
Error::RuntimeBindingFingerprintConflict { .. } => {
|
||||
RuntimeTrustConflictKind::FingerprintInUse
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let current = api
|
||||
.store
|
||||
.get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten();
|
||||
Some(
|
||||
(
|
||||
StatusCode::CONFLICT,
|
||||
Json(RuntimeTrustConflictResponse {
|
||||
error: kind,
|
||||
message: match kind {
|
||||
RuntimeTrustConflictKind::StaleRevision => {
|
||||
"the Runtime trust binding changed; reload before retrying".to_string()
|
||||
}
|
||||
RuntimeTrustConflictKind::FingerprintInUse => {
|
||||
"the public key is already bound to another Runtime in this Workspace"
|
||||
.to_string()
|
||||
}
|
||||
},
|
||||
current_revision: current.as_ref().map(|binding| binding.binding_revision),
|
||||
current_fingerprint: current
|
||||
.as_ref()
|
||||
.map(|binding| binding.public_key_fingerprint.clone()),
|
||||
}),
|
||||
)
|
||||
.into_response(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn scoped_delete_remote_runtime(
|
||||
State(api): State<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedRuntimePath>,
|
||||
Extension(actor): Extension<RequestActor>,
|
||||
) -> ApiResult<StatusCode> {
|
||||
validate_workspace_scope(&api, &path.workspace_id)?;
|
||||
require_workspace_owner(&api, &path.workspace_id, &actor, "Runtime removal").await?;
|
||||
delete_remote_runtime(State(api), AxumPath(path.runtime_id)).await
|
||||
}
|
||||
|
||||
@@ -12211,6 +12457,7 @@ async fn get_workspace(
|
||||
permissions: WorkspacePermissionSummary {
|
||||
manage_repositories: is_owner,
|
||||
manage_secrets: is_owner,
|
||||
manage_runtimes: is_owner,
|
||||
},
|
||||
extension_points: WorkspaceExtensionPoints {
|
||||
store: "sqlite".to_string(),
|
||||
@@ -12451,7 +12698,7 @@ async fn create_remote_runtime(
|
||||
}
|
||||
Err(settings_bad_request(
|
||||
"runtime_public_key_required",
|
||||
"remote Runtime registration requires an authenticated public key; use `yoi-server trust-runtime add` until the Workspace Runtime key API is available",
|
||||
"remote Runtime registration requires an authenticated public key; configure it from the Runtime detail page after the Runtime endpoint is registered",
|
||||
))
|
||||
}
|
||||
|
||||
@@ -12469,8 +12716,13 @@ async fn delete_remote_runtime(
|
||||
.store
|
||||
.get_workspace_runtime_binding(&api.config.workspace_id, &runtime_id)
|
||||
.await?
|
||||
.filter(|binding| binding.revoked_at.is_none())
|
||||
.ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?;
|
||||
if binding.revoked_at.is_none() {
|
||||
return Err(Error::RuntimeBindingConflict(
|
||||
"runtime trust is still active; revoke this Workspace's trust key with an expected revision before removing the inactive registration".to_string(),
|
||||
)
|
||||
.into());
|
||||
}
|
||||
match api
|
||||
.runtime
|
||||
.unregister_if_idle(&runtime_id, api.config.max_records.min(200))
|
||||
@@ -12499,14 +12751,6 @@ async fn delete_remote_runtime(
|
||||
));
|
||||
}
|
||||
}
|
||||
let now = Utc::now().to_rfc3339();
|
||||
if !api
|
||||
.store
|
||||
.revoke_workspace_runtime_binding_record(&binding.workspace_id, &binding.runtime_id, &now)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::UnknownRuntime(runtime_id).into());
|
||||
}
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -14488,6 +14732,133 @@ async fn workspace_runtime_resources_response(
|
||||
})
|
||||
}
|
||||
|
||||
async fn workspace_runtime_detail(
|
||||
api: &WorkspaceApi,
|
||||
workspace_id: &str,
|
||||
runtime_id: &str,
|
||||
include_public_key: bool,
|
||||
) -> ApiResult<WorkspaceRuntimeDetail> {
|
||||
let binding = api
|
||||
.store
|
||||
.get_workspace_runtime_binding(workspace_id, runtime_id)
|
||||
.await?;
|
||||
let mut resource = workspace_runtime_resources_response(api, workspace_id)
|
||||
.await?
|
||||
.items
|
||||
.into_iter()
|
||||
.find(|resource| resource.runtime.runtime_id == runtime_id);
|
||||
if resource.is_none() {
|
||||
resource = binding.as_ref().map(|binding| WorkspaceRuntimeResource {
|
||||
runtime: workspace_api::RuntimeSummary {
|
||||
runtime_id: binding.runtime_id.clone(),
|
||||
label: binding.display_name.clone(),
|
||||
kind: "remote_http".to_string(),
|
||||
status: "unavailable".to_string(),
|
||||
source: workspace_api::RuntimeSourceSummary {
|
||||
kind: workspace_api::RuntimeSourceKind::RemoteHttp,
|
||||
status: workspace_api::RuntimeSourceStatus::Reserved,
|
||||
identity_authority:
|
||||
workspace_api::RuntimeIdentityAuthority::ServerRuntimeConfiguration,
|
||||
note: "The Runtime trust binding is not active in the Runtime registry."
|
||||
.to_string(),
|
||||
},
|
||||
host_ids: Vec::new(),
|
||||
worker_creation_available: false,
|
||||
os: String::new(),
|
||||
arch: String::new(),
|
||||
diagnostics: Vec::new(),
|
||||
},
|
||||
management: RuntimeManagementSummary {
|
||||
built_in: false,
|
||||
config_managed: true,
|
||||
removable: false,
|
||||
endpoint_configured: !binding.base_url.trim().is_empty(),
|
||||
token_ref_configured: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
let mut resource = resource.ok_or_else(|| Error::UnknownRuntime(runtime_id.to_string()))?;
|
||||
if let Some(binding) = &binding {
|
||||
resource.management.config_managed = true;
|
||||
resource.management.endpoint_configured = !binding.base_url.trim().is_empty();
|
||||
}
|
||||
let endpoint = binding
|
||||
.as_ref()
|
||||
.map(|binding| binding.base_url.clone())
|
||||
.or_else(|| {
|
||||
api.config
|
||||
.remote_runtime_sources
|
||||
.iter()
|
||||
.find(|source| {
|
||||
source.runtime_id == runtime_id
|
||||
&& source.workspace_id.as_deref() == Some(workspace_id)
|
||||
})
|
||||
.map(|source| source.base_url.clone())
|
||||
});
|
||||
let trust_key = binding.as_ref().map_or(
|
||||
RuntimeTrustKeyState {
|
||||
status: RuntimeTrustKeyStatus::Unconfigured,
|
||||
public_key: None,
|
||||
fingerprint: None,
|
||||
revision: None,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
revoked_at: None,
|
||||
},
|
||||
|binding| RuntimeTrustKeyState {
|
||||
status: if binding.revoked_at.is_some() {
|
||||
RuntimeTrustKeyStatus::Revoked
|
||||
} else {
|
||||
RuntimeTrustKeyStatus::Active
|
||||
},
|
||||
public_key: include_public_key.then(|| binding.public_key.clone()),
|
||||
fingerprint: Some(binding.public_key_fingerprint.clone()),
|
||||
revision: Some(binding.binding_revision),
|
||||
created_at: Some(binding.created_at.clone()),
|
||||
updated_at: Some(binding.updated_at.clone()),
|
||||
revoked_at: binding.revoked_at.clone(),
|
||||
},
|
||||
);
|
||||
let recent_audit = api
|
||||
.store
|
||||
.list_workspace_runtime_binding_audit(workspace_id, runtime_id, 20)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(project_runtime_trust_audit)
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(WorkspaceRuntimeDetail {
|
||||
workspace_id: workspace_id.to_string(),
|
||||
runtime: resource,
|
||||
endpoint,
|
||||
trust_key,
|
||||
recent_audit,
|
||||
})
|
||||
}
|
||||
|
||||
fn project_runtime_trust_audit(
|
||||
record: WorkspaceRuntimeBindingAuditRecord,
|
||||
) -> Result<RuntimeTrustAuditEntry> {
|
||||
let action = match record.action.as_str() {
|
||||
"created" => RuntimeTrustAuditAction::Created,
|
||||
"replaced" => RuntimeTrustAuditAction::Replaced,
|
||||
"reactivated" => RuntimeTrustAuditAction::Reactivated,
|
||||
"revoked" => RuntimeTrustAuditAction::Revoked,
|
||||
other => {
|
||||
return Err(Error::Store(format!(
|
||||
"unsupported Runtime trust audit action {other}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(RuntimeTrustAuditEntry {
|
||||
action,
|
||||
actor_account_id: record.actor_account_id,
|
||||
old_fingerprint: record.old_fingerprint,
|
||||
new_fingerprint: record.new_fingerprint,
|
||||
revision: record.binding_revision,
|
||||
at: record.at,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_runtime_connection_request(request: &CreateRemoteRuntimeRequest) -> ApiResult<()> {
|
||||
validate_public_runtime_id(request.runtime_id.trim())?;
|
||||
let endpoint = request.endpoint.trim();
|
||||
@@ -16040,6 +16411,8 @@ impl IntoResponse for ApiError {
|
||||
| Error::WorkdirAttachmentConflict(_)
|
||||
| Error::WorkspaceConfigConflict(_)
|
||||
| Error::RuntimeBindingConflict(_)
|
||||
| Error::RuntimeBindingRevisionConflict { .. }
|
||||
| Error::RuntimeBindingFingerprintConflict { .. }
|
||||
| Error::RepositoryConflict(_) => StatusCode::CONFLICT,
|
||||
Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST,
|
||||
Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => {
|
||||
@@ -16068,6 +16441,7 @@ impl IntoResponse for ApiError {
|
||||
| Error::UnknownRuntime(_)
|
||||
| Error::UnknownWorker { .. }
|
||||
| Error::UnknownRepository(_)
|
||||
| Error::RuntimeBindingNotFound { .. }
|
||||
| Error::WorkspaceIdMismatch => StatusCode::NOT_FOUND,
|
||||
Error::RuntimeOperationFailed { code, .. } if code == "skill_not_found" => {
|
||||
StatusCode::NOT_FOUND
|
||||
@@ -16573,6 +16947,7 @@ mod tests {
|
||||
base_url: "https://runtime.test".to_owned(),
|
||||
public_key: identity.public_key.clone(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||
revoked_at: None,
|
||||
@@ -22068,6 +22443,166 @@ mod tests {
|
||||
assert_eq!(detail.provenance.id, "workspace:triage-errors");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn runtime_trust_management_is_owner_only_revisioned_and_redacted() {
|
||||
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(),
|
||||
account_id: owner_account_id.clone(),
|
||||
handle: "owner".to_string(),
|
||||
display_name: "Owner".to_string(),
|
||||
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(),
|
||||
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 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,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
revoked_at: None,
|
||||
},
|
||||
None,
|
||||
&owner_account_id,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let Json(owner_detail) = scoped_get_runtime_detail(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedRuntimePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
runtime_id: "runtime-a".to_string(),
|
||||
}),
|
||||
Extension(owner.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(owner_detail.trust_key.public_key.is_some());
|
||||
assert_eq!(owner_detail.trust_key.revision, Some(1));
|
||||
let Json(reader_detail) = scoped_get_runtime_detail(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedRuntimePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
runtime_id: "runtime-a".to_string(),
|
||||
}),
|
||||
Extension(non_owner.clone()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(reader_detail.trust_key.public_key.is_none());
|
||||
assert!(reader_detail.trust_key.fingerprint.is_some());
|
||||
|
||||
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));
|
||||
|
||||
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(),
|
||||
}),
|
||||
Extension(non_owner),
|
||||
Json(RevokeRuntimeTrustKeyRequest {
|
||||
expected_revision: 2,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert_eq!(denied.into_response().status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let revoked = scoped_revoke_runtime_trust_key(
|
||||
State(api.clone()),
|
||||
AxumPath(ScopedRuntimePath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
runtime_id: "runtime-a".to_string(),
|
||||
}),
|
||||
Extension(owner),
|
||||
Json(RevokeRuntimeTrustKeyRequest {
|
||||
expected_revision: 2,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(revoked.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!(binding.revoked_at.is_some());
|
||||
assert!(
|
||||
!api.runtime_binding_expectations
|
||||
.read()
|
||||
.unwrap()
|
||||
.contains_key(&(TEST_WORKSPACE_ID.to_string(), "runtime-a".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repository_secret_management_is_owner_only() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
@@ -22115,6 +22650,16 @@ mod tests {
|
||||
test_api_with_recording_backend(workspace_root).await.0
|
||||
}
|
||||
|
||||
fn test_owner_actor() -> RequestActor {
|
||||
RequestActor {
|
||||
user_id: "owner-user".to_string(),
|
||||
account_id: format!("account-{TEST_WORKSPACE_ID}"),
|
||||
handle: "owner".to_string(),
|
||||
display_name: "Owner".to_string(),
|
||||
auth_method: ActorAuthMethod::BrowserSession,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_repository_id(api: &WorkspaceApi) -> String {
|
||||
api.store
|
||||
.get_repository_by_key(TEST_WORKSPACE_ID, "test-repository")
|
||||
@@ -22602,6 +23147,7 @@ mod tests {
|
||||
base_url: "https://runtime.invalid".to_string(),
|
||||
public_key: identity.public_key.clone(),
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "2026-08-11T00:00:00Z".to_string(),
|
||||
updated_at: "2026-08-11T00:00:00Z".to_string(),
|
||||
revoked_at: None,
|
||||
@@ -24088,6 +24634,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
revoked_at: None,
|
||||
@@ -25205,6 +25752,7 @@ mod tests {
|
||||
base_url: "https://runtime.example.invalid".to_string(),
|
||||
public_key: identity.public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
revoked_at: None,
|
||||
@@ -25222,7 +25770,7 @@ mod tests {
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let app = build_inner_router(api);
|
||||
let app = build_inner_router(api.clone()).layer(Extension(test_owner_actor()));
|
||||
let runtimes_uri = format!("/api/w/{TEST_WORKSPACE_ID}/runtimes");
|
||||
|
||||
let initial = get_json(app.clone(), &runtimes_uri).await;
|
||||
@@ -25303,6 +25851,16 @@ mod tests {
|
||||
.expect("team runtime launch option");
|
||||
assert_eq!(team_runtime["working_directory_required"], true);
|
||||
|
||||
api.store
|
||||
.revoke_workspace_runtime_binding_key(
|
||||
TEST_WORKSPACE_ID,
|
||||
"team-runtime",
|
||||
1,
|
||||
&format!("account-{TEST_WORKSPACE_ID}"),
|
||||
&Utc::now().to_rfc3339(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let deleted = request_json(
|
||||
app.clone(),
|
||||
"DELETE",
|
||||
@@ -25354,6 +25912,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.public_key,
|
||||
public_key_fingerprint: String::new(),
|
||||
binding_revision: 1,
|
||||
created_at: "1".to_string(),
|
||||
updated_at: "1".to_string(),
|
||||
revoked_at: None,
|
||||
@@ -25370,7 +25929,17 @@ mod tests {
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let app = build_inner_router(api);
|
||||
api.store
|
||||
.revoke_workspace_runtime_binding_key(
|
||||
TEST_WORKSPACE_ID,
|
||||
"busy-runtime",
|
||||
1,
|
||||
&format!("account-{TEST_WORKSPACE_ID}"),
|
||||
&Utc::now().to_rfc3339(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let app = build_inner_router(api).layer(Extension(test_owner_actor()));
|
||||
let workers = get_json(app.clone(), "/api/workers").await;
|
||||
assert!(
|
||||
workers["items"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts",
|
||||
"build": "deno run -A npm:vite@7.2.7 build",
|
||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||
},
|
||||
|
||||
@@ -47,6 +47,7 @@ export type WorkspaceAuthConfig = {
|
||||
export type WorkspacePermissionSummary = {
|
||||
manage_repositories: boolean;
|
||||
manage_secrets: boolean;
|
||||
manage_runtimes: boolean;
|
||||
};
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
@@ -221,6 +222,107 @@ export type RepositoryLogResponse = {
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type RuntimeSourceKind = "embedded_worker_runtime" | "remote_http";
|
||||
|
||||
export type RuntimeSourceStatus = "active" | "reserved";
|
||||
|
||||
export type RuntimeIdentityAuthority =
|
||||
| "runtime_registry_projection"
|
||||
| "server_runtime_configuration";
|
||||
|
||||
export type RuntimeSourceSummary = {
|
||||
kind: RuntimeSourceKind;
|
||||
status: RuntimeSourceStatus;
|
||||
identity_authority: RuntimeIdentityAuthority;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type RuntimeSummary = {
|
||||
runtime_id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
source: RuntimeSourceSummary;
|
||||
host_ids: Array<string>;
|
||||
worker_creation_available: boolean;
|
||||
os: string;
|
||||
arch: string;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type RuntimeManagementSummary = {
|
||||
built_in: boolean;
|
||||
config_managed: boolean;
|
||||
removable: boolean;
|
||||
endpoint_configured: boolean;
|
||||
token_ref_configured: boolean;
|
||||
};
|
||||
|
||||
export type WorkspaceRuntimeResource = {
|
||||
management: RuntimeManagementSummary;
|
||||
runtime_id: string;
|
||||
label: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
source: RuntimeSourceSummary;
|
||||
host_ids: Array<string>;
|
||||
worker_creation_available: boolean;
|
||||
os: string;
|
||||
arch: string;
|
||||
diagnostics: Array<Diagnostic>;
|
||||
};
|
||||
|
||||
export type RuntimeTrustKeyStatus = "unconfigured" | "active" | "revoked";
|
||||
|
||||
export type RuntimeTrustKeyState = {
|
||||
status: RuntimeTrustKeyStatus;
|
||||
public_key?: string | null;
|
||||
fingerprint?: string | null;
|
||||
revision?: number | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
revoked_at?: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeTrustAuditAction =
|
||||
| "created"
|
||||
| "replaced"
|
||||
| "reactivated"
|
||||
| "revoked";
|
||||
|
||||
export type RuntimeTrustAuditEntry = {
|
||||
action: RuntimeTrustAuditAction;
|
||||
actor_account_id: string;
|
||||
old_fingerprint?: string | null;
|
||||
new_fingerprint?: string | null;
|
||||
revision: number;
|
||||
at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceRuntimeDetail = {
|
||||
workspace_id: string;
|
||||
runtime: WorkspaceRuntimeResource;
|
||||
endpoint?: string | null;
|
||||
trust_key: RuntimeTrustKeyState;
|
||||
recent_audit: Array<RuntimeTrustAuditEntry>;
|
||||
};
|
||||
|
||||
export type PutRuntimeTrustKeyRequest = {
|
||||
public_key: string;
|
||||
expected_revision: number | null;
|
||||
};
|
||||
|
||||
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
|
||||
|
||||
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
|
||||
|
||||
export type RuntimeTrustConflictResponse = {
|
||||
error: RuntimeTrustConflictKind;
|
||||
message: string;
|
||||
current_revision?: number;
|
||||
current_fingerprint?: string | null;
|
||||
};
|
||||
|
||||
export type RuntimeConnectionTestStatus = "compatible" | "failed";
|
||||
|
||||
export type RuntimeConnectionTestFailureKind =
|
||||
|
||||
@@ -0,0 +1,723 @@
|
||||
import type {
|
||||
Diagnostic,
|
||||
PutRuntimeTrustKeyRequest,
|
||||
RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeIdentityAuthority,
|
||||
RuntimeManagementSummary,
|
||||
RuntimeSourceKind,
|
||||
RuntimeSourceStatus,
|
||||
RuntimeSourceSummary,
|
||||
RuntimeTrustAuditAction,
|
||||
RuntimeTrustAuditEntry,
|
||||
RuntimeTrustConflictKind,
|
||||
RuntimeTrustConflictResponse,
|
||||
RuntimeTrustKeyState,
|
||||
RuntimeTrustKeyStatus,
|
||||
WorkspaceRuntimeDetail,
|
||||
WorkspaceRuntimeResource,
|
||||
} from "$lib/generated/workspace-api.ts";
|
||||
import type { ListResponse } from "$lib/workspace/sidebar/types";
|
||||
import { workspaceApiPath } from "./http.ts";
|
||||
|
||||
export type WorkspaceRuntimeList = ListResponse<WorkspaceRuntimeResource>;
|
||||
|
||||
const LIMITS = {
|
||||
runtimeItems: 200,
|
||||
auditEntries: 20,
|
||||
hostIds: 128,
|
||||
diagnostics: 64,
|
||||
idBytes: 256,
|
||||
labelBytes: 512,
|
||||
kindBytes: 128,
|
||||
statusBytes: 128,
|
||||
noteBytes: 2_048,
|
||||
endpointBytes: 4_096,
|
||||
publicKeyBytes: 16 * 1_024,
|
||||
fingerprintBytes: 512,
|
||||
timestampBytes: 128,
|
||||
diagnosticCodeBytes: 128,
|
||||
diagnosticMessageBytes: 2_048,
|
||||
conflictMessageBytes: 1_024,
|
||||
responseBytes: 512 * 1_024,
|
||||
} as const;
|
||||
|
||||
const SOURCE_KINDS = new Set<RuntimeSourceKind>([
|
||||
"embedded_worker_runtime",
|
||||
"remote_http",
|
||||
]);
|
||||
const SOURCE_STATUSES = new Set<RuntimeSourceStatus>(["active", "reserved"]);
|
||||
const IDENTITY_AUTHORITIES = new Set<RuntimeIdentityAuthority>([
|
||||
"runtime_registry_projection",
|
||||
"server_runtime_configuration",
|
||||
]);
|
||||
const DIAGNOSTIC_SEVERITIES = new Set(["info", "warning", "error"]);
|
||||
const TRUST_STATUSES = new Set<RuntimeTrustKeyStatus>([
|
||||
"unconfigured",
|
||||
"active",
|
||||
"revoked",
|
||||
]);
|
||||
const AUDIT_ACTIONS = new Set<RuntimeTrustAuditAction>([
|
||||
"created",
|
||||
"replaced",
|
||||
"reactivated",
|
||||
"revoked",
|
||||
]);
|
||||
const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
|
||||
"stale_revision",
|
||||
"fingerprint_in_use",
|
||||
]);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
export class RuntimeManagementValidationError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message.slice(0, 256));
|
||||
this.name = "RuntimeManagementValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class RuntimeTrustConflictError extends Error {
|
||||
readonly conflict: RuntimeTrustConflictResponse;
|
||||
|
||||
constructor(conflict: RuntimeTrustConflictResponse) {
|
||||
super(conflict.message);
|
||||
this.name = "RuntimeTrustConflictError";
|
||||
this.conflict = conflict;
|
||||
}
|
||||
}
|
||||
|
||||
export class RuntimeTrustRequestError extends Error {
|
||||
readonly field: "public_key" | null;
|
||||
|
||||
constructor(message: string, field: "public_key" | null = null) {
|
||||
super(message.slice(0, 256));
|
||||
this.name = "RuntimeTrustRequestError";
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(path: string, message: string): never {
|
||||
throw new RuntimeManagementValidationError(`${path} ${message}`);
|
||||
}
|
||||
|
||||
function object(value: unknown, path: string): JsonObject {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
return fail(path, "must be an object");
|
||||
}
|
||||
return value as JsonObject;
|
||||
}
|
||||
|
||||
function exactKeys(
|
||||
value: JsonObject,
|
||||
required: readonly string[],
|
||||
optional: readonly string[],
|
||||
path: string,
|
||||
): void {
|
||||
const allowed = new Set([...required, ...optional]);
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!allowed.has(key)) {
|
||||
fail(`${path}.${key}`, "is not part of the wire contract");
|
||||
}
|
||||
}
|
||||
for (const key of required) {
|
||||
if (!Object.hasOwn(value, key)) fail(`${path}.${key}`, "is required");
|
||||
}
|
||||
}
|
||||
|
||||
function array(value: unknown, path: string, max: number): unknown[] {
|
||||
if (!Array.isArray(value)) return fail(path, "must be an array");
|
||||
if (value.length > max) {
|
||||
return fail(path, `must contain at most ${max} items`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boundedString(
|
||||
value: unknown,
|
||||
path: string,
|
||||
maxBytes: number,
|
||||
allowEmpty = false,
|
||||
): string {
|
||||
if (typeof value !== "string") return fail(path, "must be a string");
|
||||
if (!allowEmpty && value.length === 0) return fail(path, "must not be empty");
|
||||
if (encoder.encode(value).byteLength > maxBytes) {
|
||||
return fail(path, `must be at most ${maxBytes} UTF-8 bytes`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, path: string): boolean {
|
||||
if (typeof value !== "boolean") return fail(path, "must be a boolean");
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeInteger(value: unknown, path: string, minimum = 0): number {
|
||||
if (
|
||||
typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum
|
||||
) {
|
||||
return fail(path, `must be a safe integer of at least ${minimum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeRevision(value: unknown, path: string): number {
|
||||
return safeInteger(value, path, 1);
|
||||
}
|
||||
|
||||
function optionalNullableString(
|
||||
value: unknown,
|
||||
path: string,
|
||||
maxBytes: number,
|
||||
allowEmpty = false,
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null) return value;
|
||||
return boundedString(value, path, maxBytes, allowEmpty);
|
||||
}
|
||||
|
||||
function optionalRevision(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): number | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
return safeRevision(value, path);
|
||||
}
|
||||
|
||||
function optionalNullableRevision(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): number | null | undefined {
|
||||
if (value === undefined || value === null) return value;
|
||||
return safeRevision(value, path);
|
||||
}
|
||||
|
||||
function timestamp(value: unknown, path: string): string {
|
||||
const result = boundedString(value, path, LIMITS.timestampBytes);
|
||||
if (
|
||||
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
|
||||
.test(result)
|
||||
) {
|
||||
return fail(path, "must be an RFC 3339 timestamp");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function optionalNullableTimestamp(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): string | null | undefined {
|
||||
if (value === undefined || value === null) return value;
|
||||
return timestamp(value, path);
|
||||
}
|
||||
|
||||
function enumValue<T extends string>(
|
||||
value: unknown,
|
||||
path: string,
|
||||
variants: ReadonlySet<T>,
|
||||
): T {
|
||||
const result = boundedString(value, path, LIMITS.kindBytes);
|
||||
if (!variants.has(result as T)) {
|
||||
return fail(path, "contains an unknown enum value");
|
||||
}
|
||||
return result as T;
|
||||
}
|
||||
|
||||
function diagnostic(value: unknown, path: string): Diagnostic {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["code", "severity", "message"], [], path);
|
||||
const severity = enumValue(
|
||||
item.severity,
|
||||
`${path}.severity`,
|
||||
DIAGNOSTIC_SEVERITIES,
|
||||
) as Diagnostic["severity"];
|
||||
return {
|
||||
code: boundedString(item.code, `${path}.code`, LIMITS.diagnosticCodeBytes),
|
||||
severity,
|
||||
message: boundedString(
|
||||
item.message,
|
||||
`${path}.message`,
|
||||
LIMITS.diagnosticMessageBytes,
|
||||
true,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeSource(value: unknown, path: string): RuntimeSourceSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["kind", "status", "identity_authority", "note"], [], path);
|
||||
return {
|
||||
kind: enumValue(item.kind, `${path}.kind`, SOURCE_KINDS),
|
||||
status: enumValue(item.status, `${path}.status`, SOURCE_STATUSES),
|
||||
identity_authority: enumValue(
|
||||
item.identity_authority,
|
||||
`${path}.identity_authority`,
|
||||
IDENTITY_AUTHORITIES,
|
||||
),
|
||||
note: boundedString(item.note, `${path}.note`, LIMITS.noteBytes, true),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeManagement(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): RuntimeManagementSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
[
|
||||
"built_in",
|
||||
"config_managed",
|
||||
"removable",
|
||||
"endpoint_configured",
|
||||
"token_ref_configured",
|
||||
],
|
||||
[],
|
||||
path,
|
||||
);
|
||||
return {
|
||||
built_in: boolean(item.built_in, `${path}.built_in`),
|
||||
config_managed: boolean(item.config_managed, `${path}.config_managed`),
|
||||
removable: boolean(item.removable, `${path}.removable`),
|
||||
endpoint_configured: boolean(
|
||||
item.endpoint_configured,
|
||||
`${path}.endpoint_configured`,
|
||||
),
|
||||
token_ref_configured: boolean(
|
||||
item.token_ref_configured,
|
||||
`${path}.token_ref_configured`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeResource(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): WorkspaceRuntimeResource {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
[
|
||||
"management",
|
||||
"runtime_id",
|
||||
"label",
|
||||
"kind",
|
||||
"status",
|
||||
"source",
|
||||
"host_ids",
|
||||
"worker_creation_available",
|
||||
"os",
|
||||
"arch",
|
||||
"diagnostics",
|
||||
],
|
||||
[],
|
||||
path,
|
||||
);
|
||||
const hostIds = array(item.host_ids, `${path}.host_ids`, LIMITS.hostIds).map(
|
||||
(entry, index) =>
|
||||
boundedString(
|
||||
entry,
|
||||
`${path}.host_ids[${index}]`,
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
);
|
||||
if (new Set(hostIds).size !== hostIds.length) {
|
||||
fail(`${path}.host_ids`, "must not contain duplicate IDs");
|
||||
}
|
||||
return {
|
||||
management: runtimeManagement(item.management, `${path}.management`),
|
||||
runtime_id: boundedString(
|
||||
item.runtime_id,
|
||||
`${path}.runtime_id`,
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
label: boundedString(item.label, `${path}.label`, LIMITS.labelBytes),
|
||||
kind: boundedString(item.kind, `${path}.kind`, LIMITS.kindBytes),
|
||||
status: boundedString(item.status, `${path}.status`, LIMITS.statusBytes),
|
||||
source: runtimeSource(item.source, `${path}.source`),
|
||||
host_ids: hostIds,
|
||||
worker_creation_available: boolean(
|
||||
item.worker_creation_available,
|
||||
`${path}.worker_creation_available`,
|
||||
),
|
||||
os: boundedString(item.os, `${path}.os`, LIMITS.kindBytes, true),
|
||||
arch: boundedString(item.arch, `${path}.arch`, LIMITS.kindBytes, true),
|
||||
diagnostics: array(
|
||||
item.diagnostics,
|
||||
`${path}.diagnostics`,
|
||||
LIMITS.diagnostics,
|
||||
).map((entry, index) => diagnostic(entry, `${path}.diagnostics[${index}]`)),
|
||||
};
|
||||
}
|
||||
|
||||
function trustKey(value: unknown, path: string): RuntimeTrustKeyState {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
["status"],
|
||||
[
|
||||
"public_key",
|
||||
"fingerprint",
|
||||
"revision",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"revoked_at",
|
||||
],
|
||||
path,
|
||||
);
|
||||
const result: RuntimeTrustKeyState = {
|
||||
status: enumValue(item.status, `${path}.status`, TRUST_STATUSES),
|
||||
public_key: optionalNullableString(
|
||||
item.public_key,
|
||||
`${path}.public_key`,
|
||||
LIMITS.publicKeyBytes,
|
||||
),
|
||||
fingerprint: optionalNullableString(
|
||||
item.fingerprint,
|
||||
`${path}.fingerprint`,
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
revision: optionalNullableRevision(item.revision, `${path}.revision`),
|
||||
created_at: optionalNullableTimestamp(
|
||||
item.created_at,
|
||||
`${path}.created_at`,
|
||||
),
|
||||
updated_at: optionalNullableTimestamp(
|
||||
item.updated_at,
|
||||
`${path}.updated_at`,
|
||||
),
|
||||
revoked_at: optionalNullableTimestamp(
|
||||
item.revoked_at,
|
||||
`${path}.revoked_at`,
|
||||
),
|
||||
};
|
||||
|
||||
const hasBinding = result.status !== "unconfigured";
|
||||
if (
|
||||
hasBinding &&
|
||||
(result.fingerprint == null || result.revision == null ||
|
||||
result.created_at == null || result.updated_at == null)
|
||||
) {
|
||||
fail(
|
||||
path,
|
||||
"must include fingerprint, revision, created_at, and updated_at",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!hasBinding &&
|
||||
Object.entries(result).some(([key, entry]) =>
|
||||
key !== "status" && entry != null
|
||||
)
|
||||
) {
|
||||
fail(path, "must not include binding values while unconfigured");
|
||||
}
|
||||
if (result.status === "revoked" && result.revoked_at == null) {
|
||||
fail(`${path}.revoked_at`, "is required for a revoked key");
|
||||
}
|
||||
if (result.status === "active" && result.revoked_at != null) {
|
||||
fail(`${path}.revoked_at`, "must be absent for an active key");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function auditEntry(value: unknown, path: string): RuntimeTrustAuditEntry {
|
||||
const item = object(value, path);
|
||||
exactKeys(
|
||||
item,
|
||||
["action", "actor_account_id", "revision", "at"],
|
||||
["old_fingerprint", "new_fingerprint"],
|
||||
path,
|
||||
);
|
||||
return {
|
||||
action: enumValue(item.action, `${path}.action`, AUDIT_ACTIONS),
|
||||
actor_account_id: boundedString(
|
||||
item.actor_account_id,
|
||||
`${path}.actor_account_id`,
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
old_fingerprint: optionalNullableString(
|
||||
item.old_fingerprint,
|
||||
`${path}.old_fingerprint`,
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
new_fingerprint: optionalNullableString(
|
||||
item.new_fingerprint,
|
||||
`${path}.new_fingerprint`,
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
revision: safeRevision(item.revision, `${path}.revision`),
|
||||
at: timestamp(item.at, `${path}.at`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkspaceRuntimeList(
|
||||
value: unknown,
|
||||
): WorkspaceRuntimeList {
|
||||
const response = object(value, "Runtime list response");
|
||||
exactKeys(
|
||||
response,
|
||||
["workspace_id", "limit", "items", "source", "diagnostics"],
|
||||
[],
|
||||
"Runtime list response",
|
||||
);
|
||||
const limit = safeInteger(response.limit, "Runtime list response.limit", 0);
|
||||
if (limit > LIMITS.runtimeItems) {
|
||||
fail(
|
||||
"Runtime list response.limit",
|
||||
`must not exceed ${LIMITS.runtimeItems}`,
|
||||
);
|
||||
}
|
||||
const items = array(
|
||||
response.items,
|
||||
"Runtime list response.items",
|
||||
LIMITS.runtimeItems,
|
||||
).map((entry, index) =>
|
||||
runtimeResource(entry, `Runtime list response.items[${index}]`)
|
||||
);
|
||||
if (items.length > limit) {
|
||||
fail("Runtime list response.items", "must not exceed the declared limit");
|
||||
}
|
||||
return {
|
||||
workspace_id: boundedString(
|
||||
response.workspace_id,
|
||||
"Runtime list response.workspace_id",
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
limit,
|
||||
items,
|
||||
source: boundedString(
|
||||
response.source,
|
||||
"Runtime list response.source",
|
||||
LIMITS.kindBytes,
|
||||
),
|
||||
diagnostics: array(
|
||||
response.diagnostics,
|
||||
"Runtime list response.diagnostics",
|
||||
LIMITS.diagnostics,
|
||||
).map((entry, index) =>
|
||||
diagnostic(entry, `Runtime list response.diagnostics[${index}]`)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseWorkspaceRuntimeDetail(
|
||||
value: unknown,
|
||||
): WorkspaceRuntimeDetail {
|
||||
const response = object(value, "Runtime detail response");
|
||||
exactKeys(
|
||||
response,
|
||||
["workspace_id", "runtime", "trust_key", "recent_audit"],
|
||||
["endpoint"],
|
||||
"Runtime detail response",
|
||||
);
|
||||
return {
|
||||
workspace_id: boundedString(
|
||||
response.workspace_id,
|
||||
"Runtime detail response.workspace_id",
|
||||
LIMITS.idBytes,
|
||||
),
|
||||
runtime: runtimeResource(
|
||||
response.runtime,
|
||||
"Runtime detail response.runtime",
|
||||
),
|
||||
endpoint: optionalNullableString(
|
||||
response.endpoint,
|
||||
"Runtime detail response.endpoint",
|
||||
LIMITS.endpointBytes,
|
||||
),
|
||||
trust_key: trustKey(
|
||||
response.trust_key,
|
||||
"Runtime detail response.trust_key",
|
||||
),
|
||||
recent_audit: array(
|
||||
response.recent_audit,
|
||||
"Runtime detail response.recent_audit",
|
||||
LIMITS.auditEntries,
|
||||
).map((entry, index) =>
|
||||
auditEntry(entry, `Runtime detail response.recent_audit[${index}]`)
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseRuntimeTrustConflict(
|
||||
value: unknown,
|
||||
): RuntimeTrustConflictResponse {
|
||||
const response = object(value, "Runtime trust conflict");
|
||||
exactKeys(
|
||||
response,
|
||||
["error", "message"],
|
||||
["current_revision", "current_fingerprint"],
|
||||
"Runtime trust conflict",
|
||||
);
|
||||
return {
|
||||
error: enumValue(
|
||||
response.error,
|
||||
"Runtime trust conflict.error",
|
||||
CONFLICT_KINDS,
|
||||
),
|
||||
message: boundedString(
|
||||
response.message,
|
||||
"Runtime trust conflict.message",
|
||||
LIMITS.conflictMessageBytes,
|
||||
),
|
||||
current_revision: optionalRevision(
|
||||
response.current_revision,
|
||||
"Runtime trust conflict.current_revision",
|
||||
),
|
||||
current_fingerprint: optionalNullableString(
|
||||
response.current_fingerprint,
|
||||
"Runtime trust conflict.current_fingerprint",
|
||||
LIMITS.fingerprintBytes,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function revisionForJson(revision: number | null): number | null {
|
||||
if (revision === null) return null;
|
||||
if (!Number.isSafeInteger(revision) || revision < 1) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust revision is not a safe integer",
|
||||
);
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
async function readBoundedJson(response: Response): Promise<unknown> {
|
||||
const contentLength = response.headers.get("content-length");
|
||||
if (contentLength !== null) {
|
||||
const parsed = Number(contentLength);
|
||||
if (Number.isFinite(parsed) && parsed > LIMITS.responseBytes) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust response exceeds its byte limit",
|
||||
);
|
||||
}
|
||||
}
|
||||
const text = await response.text();
|
||||
if (encoder.encode(text).byteLength > LIMITS.responseBytes) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust response exceeds its byte limit",
|
||||
);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust response is not valid JSON",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function requestErrorFrom(
|
||||
value: unknown,
|
||||
status: number,
|
||||
): RuntimeTrustRequestError {
|
||||
try {
|
||||
const response = object(value, "Runtime trust error");
|
||||
exactKeys(
|
||||
response,
|
||||
["error", "message", "diagnostics"],
|
||||
[],
|
||||
"Runtime trust error",
|
||||
);
|
||||
const diagnostics = array(
|
||||
response.diagnostics,
|
||||
"Runtime trust error.diagnostics",
|
||||
LIMITS.diagnostics,
|
||||
).map((entry, index) =>
|
||||
diagnostic(entry, `Runtime trust error.diagnostics[${index}]`)
|
||||
);
|
||||
const message = boundedString(
|
||||
response.message,
|
||||
"Runtime trust error.message",
|
||||
LIMITS.conflictMessageBytes,
|
||||
);
|
||||
const field = diagnostics.some((entry) =>
|
||||
entry.code.startsWith("runtime_public_key_")
|
||||
)
|
||||
? "public_key"
|
||||
: null;
|
||||
return new RuntimeTrustRequestError(message, field);
|
||||
} catch {
|
||||
return new RuntimeTrustRequestError(
|
||||
`Runtime trust request failed (${status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishMutation(
|
||||
response: Response,
|
||||
workspaceId: string,
|
||||
runtimeId: string,
|
||||
): Promise<WorkspaceRuntimeDetail> {
|
||||
const payload = await readBoundedJson(response);
|
||||
if (response.status === 409) {
|
||||
try {
|
||||
throw new RuntimeTrustConflictError(parseRuntimeTrustConflict(payload));
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeTrustConflictError) throw error;
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust conflict response was invalid",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!response.ok) throw requestErrorFrom(payload, response.status);
|
||||
let detail: WorkspaceRuntimeDetail;
|
||||
try {
|
||||
detail = parseWorkspaceRuntimeDetail(payload);
|
||||
} catch {
|
||||
throw new RuntimeTrustRequestError("Runtime trust response was invalid");
|
||||
}
|
||||
if (
|
||||
detail.workspace_id !== workspaceId ||
|
||||
detail.runtime.runtime_id !== runtimeId
|
||||
) {
|
||||
throw new RuntimeTrustRequestError(
|
||||
"Runtime trust response did not match the selected Runtime",
|
||||
);
|
||||
}
|
||||
return detail;
|
||||
}
|
||||
|
||||
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,
|
||||
request: RevokeRuntimeTrustKeyRequest,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<WorkspaceRuntimeDetail> {
|
||||
const response = await fetchImpl(
|
||||
workspaceApiPath(
|
||||
workspaceId,
|
||||
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
|
||||
),
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
expected_revision: revisionForJson(request.expected_revision),
|
||||
}),
|
||||
},
|
||||
);
|
||||
return await finishMutation(response, workspaceId, runtimeId);
|
||||
}
|
||||
@@ -367,13 +367,18 @@ function authConfig(value: unknown, path: string): WorkspaceAuthConfig {
|
||||
|
||||
function permissions(value: unknown, path: string): WorkspacePermissionSummary {
|
||||
const item = object(value, path);
|
||||
exactKeys(item, ["manage_repositories", "manage_secrets"], path);
|
||||
exactKeys(
|
||||
item,
|
||||
["manage_repositories", "manage_secrets", "manage_runtimes"],
|
||||
path,
|
||||
);
|
||||
return {
|
||||
manage_repositories: boolean(
|
||||
item.manage_repositories,
|
||||
`${path}.manage_repositories`,
|
||||
),
|
||||
manage_secrets: boolean(item.manage_secrets, `${path}.manage_secrets`),
|
||||
manage_runtimes: boolean(item.manage_runtimes, `${path}.manage_runtimes`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -342,6 +342,196 @@
|
||||
.settings-test-result.failed {
|
||||
border-inline-start: 3px solid var(--danger);
|
||||
}
|
||||
|
||||
.runtime-detail-page {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.runtime-detail-section {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
padding-top: var(--space-4);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.runtime-detail-section h2,
|
||||
.runtime-detail-section p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.runtime-detail-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
|
||||
gap: var(--space-3) var(--space-5);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.runtime-detail-grid div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runtime-detail-grid dt {
|
||||
margin-bottom: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.runtime-detail-grid dd {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.runtime-public-key-actions,
|
||||
.runtime-revoke-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.runtime-public-key-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.runtime-public-key-actions button,
|
||||
.runtime-revoke-row button,
|
||||
.runtime-trust-form button {
|
||||
border: 0;
|
||||
border-radius: 0.6rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.runtime-public-key-actions button.secondary {
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--text-strong);
|
||||
}
|
||||
|
||||
.runtime-public-key-actions button:disabled,
|
||||
.runtime-revoke-row button:disabled,
|
||||
.runtime-trust-form button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.runtime-public-key,
|
||||
.runtime-trust-form textarea,
|
||||
.runtime-trust-form input {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.5rem;
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-strong);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.runtime-public-key {
|
||||
max-height: 14rem;
|
||||
margin: 0;
|
||||
padding: var(--space-3);
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.runtime-trust-form {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
max-width: 56rem;
|
||||
}
|
||||
|
||||
.runtime-trust-form label {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runtime-trust-form textarea,
|
||||
.runtime-trust-form input {
|
||||
width: 100%;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.runtime-trust-form textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.runtime-trust-form small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.runtime-trust-form .field-error,
|
||||
.runtime-detail-page .section-state.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.runtime-detail-page .section-state.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.runtime-revoke-row {
|
||||
padding-top: var(--space-3);
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.runtime-revoke-row div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.runtime-revoke-row p {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.runtime-revoke-row button.danger {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.runtime-audit-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.runtime-audit-table {
|
||||
width: 100%;
|
||||
min-width: 48rem;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.runtime-audit-table th,
|
||||
.runtime-audit-table td {
|
||||
padding: 0.7rem 0.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.runtime-audit-table th {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.runtime-audit-table code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.runtime-revoke-row {
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-page {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type { RuntimeConnectionTestResponse } from '$lib/generated/workspace-api';
|
||||
import type {
|
||||
RuntimeConnectionTestResponse,
|
||||
WorkspaceRuntimeResource,
|
||||
} from '$lib/generated/workspace-api';
|
||||
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
|
||||
import { workspaceApiPath } from '$lib/workspace/api/http';
|
||||
import type { Runtime } from '$lib/workspace/sidebar/types';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
@@ -15,7 +17,7 @@
|
||||
let requestError = $state<string | null>(null);
|
||||
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
|
||||
|
||||
function runtimePlatform(runtime: Runtime): string {
|
||||
function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
|
||||
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
|
||||
}
|
||||
|
||||
@@ -38,7 +40,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function managementLabel(runtime: Runtime): string {
|
||||
function managementLabel(runtime: WorkspaceRuntimeResource): string {
|
||||
if (runtime.management?.built_in) return 'Built-in';
|
||||
if (runtime.management?.config_managed) return 'Managed remote';
|
||||
return 'Observed';
|
||||
@@ -78,27 +80,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteRuntime(runtime: Runtime): Promise<void> {
|
||||
requestError = null;
|
||||
busyRuntimeId = runtime.runtime_id;
|
||||
try {
|
||||
const response = await fetch(
|
||||
workspaceApiPath(data.workspaceId, `/runtimes/${encodeURIComponent(runtime.runtime_id)}`),
|
||||
{ method: 'DELETE' },
|
||||
);
|
||||
if (!response.ok) throw new Error(await responseError(response));
|
||||
const nextResults = { ...testResults };
|
||||
delete nextResults[runtime.runtime_id];
|
||||
testResults = nextResults;
|
||||
await invalidateAll();
|
||||
} catch (error) {
|
||||
requestError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
busyRuntimeId = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function testRuntime(runtime: Runtime): Promise<void> {
|
||||
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
|
||||
requestError = null;
|
||||
busyRuntimeId = runtime.runtime_id;
|
||||
try {
|
||||
@@ -123,12 +105,14 @@
|
||||
<h1 id="runtimes-heading">Runtimes</h1>
|
||||
<p>Register and inspect the execution backends available to this Workspace.</p>
|
||||
</div>
|
||||
{#if data.workspace.permissions.manage_runtimes}
|
||||
<button type="button" onclick={() => showAddRuntime = !showAddRuntime}>
|
||||
{showAddRuntime ? 'Close' : 'Add Runtime'}
|
||||
</button>
|
||||
{/if}
|
||||
</header>
|
||||
|
||||
{#if showAddRuntime}
|
||||
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
|
||||
<form class="settings-runtime-form" onsubmit={addRuntime}>
|
||||
<h2>Add remote Runtime</h2>
|
||||
<div class="settings-form-grid">
|
||||
@@ -182,7 +166,11 @@
|
||||
{#each data.runtimes.items as runtime}
|
||||
<tr class:inactive={runtime.status !== 'active'}>
|
||||
<td>
|
||||
<strong>{runtime.label}</strong>
|
||||
<strong>
|
||||
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}`}>
|
||||
{runtime.label}
|
||||
</a>
|
||||
</strong>
|
||||
<small><code>{runtime.runtime_id}</code></small>
|
||||
</td>
|
||||
<td>{runtime.kind}</td>
|
||||
@@ -203,15 +191,8 @@
|
||||
onclick={() => testRuntime(runtime)}
|
||||
>Test</button>
|
||||
{/if}
|
||||
{#if runtime.management?.removable}
|
||||
<button
|
||||
class="danger"
|
||||
type="button"
|
||||
disabled={busyRuntimeId !== null}
|
||||
onclick={() => deleteRuntime(runtime)}
|
||||
>Delete</button>
|
||||
{:else}
|
||||
<span class="settings-muted-action">Not removable</span>
|
||||
{#if !runtime.management?.config_managed}
|
||||
<span class="settings-muted-action">Test unavailable</span>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import type { ListResponse, Runtime } from "$lib/workspace/sidebar/types";
|
||||
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const runtimes = await loadJson<ListResponse<Runtime>>(
|
||||
const runtimes = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(params.workspaceId, "/runtimes"),
|
||||
undefined,
|
||||
(value) => {
|
||||
const response = parseWorkspaceRuntimeList(value);
|
||||
if (response.workspace_id !== params.workspaceId) {
|
||||
throw new Error("Runtime list Workspace did not match the route");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
<script lang="ts">
|
||||
import { invalidateAll } from '$app/navigation';
|
||||
import type {
|
||||
PutRuntimeTrustKeyRequest,
|
||||
RevokeRuntimeTrustKeyRequest,
|
||||
RuntimeTrustKeyStatus,
|
||||
} from '$lib/generated/workspace-api';
|
||||
import {
|
||||
putRuntimeTrustKey,
|
||||
revokeRuntimeTrustKey,
|
||||
RuntimeTrustConflictError,
|
||||
RuntimeTrustRequestError,
|
||||
} from '$lib/workspace/api/runtime-management';
|
||||
import type { PageProps } from './$types';
|
||||
|
||||
type TrustAction = 'create' | 'replace' | 'reactivate';
|
||||
|
||||
let { data }: PageProps = $props();
|
||||
let revealPublicKey = $state(false);
|
||||
let publicKey = $state('');
|
||||
let fingerprintConfirmation = $state('');
|
||||
let busyAction = $state<'save' | 'revoke' | 'copy' | null>(null);
|
||||
let fieldError = $state<string | null>(null);
|
||||
let requestError = $state<string | null>(null);
|
||||
let successMessage = $state<string | null>(null);
|
||||
|
||||
function trustAction(status: RuntimeTrustKeyStatus): TrustAction {
|
||||
if (status === 'unconfigured') return 'create';
|
||||
if (status === 'revoked') return 'reactivate';
|
||||
return 'replace';
|
||||
}
|
||||
|
||||
function actionLabel(action: TrustAction): string {
|
||||
switch (action) {
|
||||
case 'create': return 'Create Workspace trust';
|
||||
case 'replace': return 'Replace trusted key';
|
||||
case 'reactivate': return 'Reactivate with this key';
|
||||
}
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null | undefined): string {
|
||||
if (!value) return '—';
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function utf8Bytes(value: string): number {
|
||||
return new TextEncoder().encode(value).byteLength;
|
||||
}
|
||||
|
||||
async function reloadAuthority(): Promise<void> {
|
||||
await invalidateAll();
|
||||
}
|
||||
|
||||
async function saveTrustKey(event: SubmitEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (busyAction !== null || !data.runtimeDetail) return;
|
||||
|
||||
fieldError = null;
|
||||
requestError = null;
|
||||
successMessage = null;
|
||||
|
||||
const key = publicKey.trim();
|
||||
if (!key) {
|
||||
fieldError = 'Enter the Runtime public key.';
|
||||
return;
|
||||
}
|
||||
if (utf8Bytes(key) > 16 * 1024) {
|
||||
fieldError = 'Public key must be at most 16 KiB of UTF-8 text.';
|
||||
return;
|
||||
}
|
||||
|
||||
const trust = data.runtimeDetail.trust_key;
|
||||
const action = trustAction(trust.status);
|
||||
if (action !== 'create') {
|
||||
if (!trust.fingerprint) {
|
||||
requestError = 'The authoritative fingerprint is unavailable. Reload before changing trust.';
|
||||
return;
|
||||
}
|
||||
if (fingerprintConfirmation.trim() !== trust.fingerprint) {
|
||||
fieldError = 'Enter the current fingerprint exactly to confirm this change.';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const request: PutRuntimeTrustKeyRequest = {
|
||||
public_key: key,
|
||||
expected_revision: trust.revision ?? null,
|
||||
};
|
||||
|
||||
busyAction = 'save';
|
||||
try {
|
||||
await putRuntimeTrustKey(data.workspaceId, data.runtimeId, request);
|
||||
publicKey = '';
|
||||
fingerprintConfirmation = '';
|
||||
revealPublicKey = false;
|
||||
successMessage = action === 'create'
|
||||
? 'Workspace trust was created.'
|
||||
: action === 'replace'
|
||||
? 'The trusted Runtime key was replaced.'
|
||||
: 'Workspace trust was reactivated.';
|
||||
await reloadAuthority();
|
||||
} catch (error) {
|
||||
fingerprintConfirmation = '';
|
||||
if (error instanceof RuntimeTrustConflictError) {
|
||||
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
||||
await reloadAuthority();
|
||||
} else if (error instanceof RuntimeTrustRequestError && error.field === 'public_key') {
|
||||
fieldError = error.message;
|
||||
} else {
|
||||
requestError = error instanceof Error ? error.message : 'Runtime trust update failed.';
|
||||
}
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeTrust(): Promise<void> {
|
||||
if (busyAction !== null || !data.runtimeDetail) return;
|
||||
const trust = data.runtimeDetail.trust_key;
|
||||
if (trust.revision == null || trust.status !== 'active') {
|
||||
requestError = 'Only active Workspace trust can be revoked.';
|
||||
return;
|
||||
}
|
||||
|
||||
fieldError = null;
|
||||
requestError = null;
|
||||
successMessage = null;
|
||||
busyAction = 'revoke';
|
||||
const request: RevokeRuntimeTrustKeyRequest = {
|
||||
expected_revision: trust.revision,
|
||||
};
|
||||
|
||||
try {
|
||||
await revokeRuntimeTrustKey(data.workspaceId, data.runtimeId, request);
|
||||
publicKey = '';
|
||||
fingerprintConfirmation = '';
|
||||
revealPublicKey = false;
|
||||
successMessage = 'Workspace trust was revoked.';
|
||||
await reloadAuthority();
|
||||
} catch (error) {
|
||||
if (error instanceof RuntimeTrustConflictError) {
|
||||
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
|
||||
await reloadAuthority();
|
||||
} else {
|
||||
requestError = error instanceof Error ? error.message : 'Runtime trust revoke failed.';
|
||||
}
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyPublicKey(): Promise<void> {
|
||||
const key = data.runtimeDetail?.trust_key.public_key;
|
||||
if (!key || busyAction !== null) return;
|
||||
busyAction = 'copy';
|
||||
requestError = null;
|
||||
try {
|
||||
await navigator.clipboard.writeText(key);
|
||||
successMessage = 'Public key copied.';
|
||||
} catch {
|
||||
requestError = 'The browser could not copy the public key.';
|
||||
} finally {
|
||||
busyAction = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{data.runtimeDetail?.runtime.label ?? data.runtimeId} · Runtime Settings · Yoi Workspace</title>
|
||||
<meta name="description" content="Runtime identity and Workspace trust settings" />
|
||||
</svelte:head>
|
||||
|
||||
<section class="runtime-detail-page" aria-labelledby="runtime-detail-heading">
|
||||
<header class="page-header-row">
|
||||
<div>
|
||||
<a class="inline-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`}>Runtimes</a>
|
||||
<h1 id="runtime-detail-heading">{data.runtimeDetail?.runtime.label ?? data.runtimeId}</h1>
|
||||
<p><code>{data.runtimeId}</code></p>
|
||||
</div>
|
||||
<a class="button-link" href={`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes/${encodeURIComponent(data.runtimeId)}/workdirs`}>
|
||||
Workdirs
|
||||
</a>
|
||||
</header>
|
||||
|
||||
{#if data.runtimeDetailError}
|
||||
<p class="section-state error">{data.runtimeDetailError}</p>
|
||||
{:else if !data.runtimeDetail}
|
||||
<p class="section-state">Loading Runtime…</p>
|
||||
{:else}
|
||||
{@const detail = data.runtimeDetail}
|
||||
{@const runtime = detail.runtime}
|
||||
{@const trust = detail.trust_key}
|
||||
{@const currentAction = trustAction(trust.status)}
|
||||
|
||||
<section class="runtime-detail-section" aria-labelledby="runtime-identity-heading">
|
||||
<h2 id="runtime-identity-heading">Identity and binding</h2>
|
||||
<dl class="runtime-detail-grid">
|
||||
<div><dt>Runtime ID</dt><dd><code>{runtime.runtime_id}</code></dd></div>
|
||||
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
|
||||
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
|
||||
<div><dt>Status</dt><dd>{runtime.status}</dd></div>
|
||||
<div><dt>Binding status</dt><dd>{trust.status}</dd></div>
|
||||
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
|
||||
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
|
||||
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
|
||||
<div><dt>Updated</dt><dd>{formatTimestamp(trust.updated_at)}</dd></div>
|
||||
<div><dt>Revoked</dt><dd>{formatTimestamp(trust.revoked_at)}</dd></div>
|
||||
</dl>
|
||||
{#if runtime.diagnostics.length > 0}
|
||||
<ul class="settings-diagnostics-list">
|
||||
{#each runtime.diagnostics as diagnostic}
|
||||
<li class:error={diagnostic.severity === 'error'} class:warning={diagnostic.severity === 'warning'}>
|
||||
<strong>{diagnostic.code}</strong>
|
||||
<span>{diagnostic.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if data.workspace.permissions.manage_runtimes}
|
||||
<section class="runtime-detail-section" aria-labelledby="runtime-trust-heading">
|
||||
<h2 id="runtime-trust-heading">Workspace trust</h2>
|
||||
|
||||
{#if trust.public_key}
|
||||
<div class="runtime-public-key-actions">
|
||||
<button type="button" class="secondary" onclick={() => revealPublicKey = !revealPublicKey}>
|
||||
{revealPublicKey ? 'Hide public key' : 'Reveal public key'}
|
||||
</button>
|
||||
<button type="button" class="secondary" disabled={busyAction !== null} onclick={copyPublicKey}>
|
||||
{busyAction === 'copy' ? 'Copying…' : 'Copy public key'}
|
||||
</button>
|
||||
</div>
|
||||
{#if revealPublicKey}
|
||||
<pre class="runtime-public-key"><code>{trust.public_key}</code></pre>
|
||||
{/if}
|
||||
{:else if trust.status !== 'unconfigured'}
|
||||
<p class="section-state">The public key was not included in this authorized response.</p>
|
||||
{/if}
|
||||
|
||||
<form class="runtime-trust-form" onsubmit={saveTrustKey}>
|
||||
<label for="runtime-public-key-input">Runtime public key</label>
|
||||
<textarea
|
||||
id="runtime-public-key-input"
|
||||
bind:value={publicKey}
|
||||
rows="5"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
aria-describedby={fieldError ? 'runtime-public-key-error' : undefined}
|
||||
aria-invalid={fieldError ? 'true' : undefined}
|
||||
placeholder="ssh-ed25519 …"
|
||||
></textarea>
|
||||
|
||||
{#if currentAction !== 'create'}
|
||||
<label for="runtime-fingerprint-confirmation">Confirm current fingerprint</label>
|
||||
<input
|
||||
id="runtime-fingerprint-confirmation"
|
||||
bind:value={fingerprintConfirmation}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
placeholder={trust.fingerprint ?? ''}
|
||||
/>
|
||||
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly.</small>
|
||||
{/if}
|
||||
|
||||
{#if fieldError}
|
||||
<p id="runtime-public-key-error" class="field-error">{fieldError}</p>
|
||||
{/if}
|
||||
<div class="settings-action-row">
|
||||
<button type="submit" disabled={busyAction !== null}>
|
||||
{busyAction === 'save' ? 'Saving…' : actionLabel(currentAction)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="runtime-revoke-row">
|
||||
<div>
|
||||
<strong>Revoke Workspace trust</strong>
|
||||
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="danger"
|
||||
disabled={busyAction !== null || trust.status !== 'active'}
|
||||
onclick={revokeTrust}
|
||||
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
|
||||
</div>
|
||||
|
||||
{#if requestError}
|
||||
<p class="section-state error" role="alert">{requestError}</p>
|
||||
{/if}
|
||||
{#if successMessage}
|
||||
<p class="section-state success" role="status">{successMessage}</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<section class="runtime-detail-section" aria-labelledby="runtime-audit-heading">
|
||||
<h2 id="runtime-audit-heading">Recent trust audit</h2>
|
||||
{#if detail.recent_audit.length === 0}
|
||||
<p class="section-state">No trust changes are recorded.</p>
|
||||
{:else}
|
||||
<div class="runtime-audit-table-wrap">
|
||||
<table class="runtime-audit-table">
|
||||
<thead>
|
||||
<tr><th>Action</th><th>Revision</th><th>Fingerprint</th><th>Actor</th><th>Time</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each detail.recent_audit as entry}
|
||||
<tr>
|
||||
<td>{entry.action}</td>
|
||||
<td>{entry.revision.toString()}</td>
|
||||
<td><code>{entry.new_fingerprint ?? entry.old_fingerprint ?? '—'}</code></td>
|
||||
<td><code>{entry.actor_account_id}</code></td>
|
||||
<td>{formatTimestamp(entry.at)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</section>
|
||||
@@ -0,0 +1,31 @@
|
||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||
import { parseWorkspaceRuntimeDetail } from "$lib/workspace/api/runtime-management";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const detail = await loadJson(
|
||||
fetch,
|
||||
workspaceApiPath(
|
||||
params.workspaceId,
|
||||
`/runtimes/${encodeURIComponent(params.runtimeId)}`,
|
||||
),
|
||||
undefined,
|
||||
(value) => {
|
||||
const response = parseWorkspaceRuntimeDetail(value);
|
||||
if (
|
||||
response.workspace_id !== params.workspaceId ||
|
||||
response.runtime.runtime_id !== params.runtimeId
|
||||
) {
|
||||
throw new Error("Runtime detail did not match the route");
|
||||
}
|
||||
return response;
|
||||
},
|
||||
);
|
||||
|
||||
return {
|
||||
workspaceId: params.workspaceId,
|
||||
runtimeId: params.runtimeId,
|
||||
runtimeDetail: detail.data,
|
||||
runtimeDetailError: detail.error,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
readTextFile(path: URL): Promise<string>;
|
||||
};
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
Deno.test("Runtime Settings routes validate unknown JSON through the shared Runtime parser", async () => {
|
||||
const [listLoader, detailLoader] = await Promise.all([
|
||||
Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/runtimes/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
assert(
|
||||
listLoader.includes("parseWorkspaceRuntimeList(value)"),
|
||||
"Runtime list loader should validate unknown JSON",
|
||||
);
|
||||
assert(
|
||||
detailLoader.includes("parseWorkspaceRuntimeDetail(value)"),
|
||||
"Runtime detail loader should validate unknown JSON",
|
||||
);
|
||||
for (const source of [listLoader, detailLoader]) {
|
||||
assert(
|
||||
!source.includes("loadJson<"),
|
||||
"Runtime loaders must not cast response JSON to a handwritten DTO",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => {
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/runtimes/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
assert(
|
||||
page.includes(
|
||||
"/settings/runtimes/${encodeURIComponent(runtime.runtime_id)}",
|
||||
),
|
||||
"Runtime name should link to canonical detail",
|
||||
);
|
||||
assert(
|
||||
page.includes("testRuntime(runtime)"),
|
||||
"connection Test should remain available",
|
||||
);
|
||||
assert(page.includes("Add Runtime"), "Add Runtime should remain available");
|
||||
assert(
|
||||
page.includes("data.workspace.permissions.manage_runtimes"),
|
||||
"Add Runtime should be hidden from non-owners",
|
||||
);
|
||||
assert(
|
||||
!page.includes("deleteRuntime"),
|
||||
"inline Runtime delete logic must be removed",
|
||||
);
|
||||
assert(
|
||||
!page.includes(">Delete</button>"),
|
||||
"inline Runtime delete control must be removed",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", async () => {
|
||||
const page = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
|
||||
const ownerGate = page.indexOf("data.workspace.permissions.manage_runtimes");
|
||||
const reveal = page.indexOf("Reveal public key");
|
||||
const mutation = page.indexOf('id="runtime-public-key-input"');
|
||||
assert(ownerGate >= 0, "Runtime trust controls should use manage_runtimes");
|
||||
assert(
|
||||
ownerGate < reveal && ownerGate < mutation,
|
||||
"owner gate should wrap key controls",
|
||||
);
|
||||
|
||||
for (
|
||||
const token of [
|
||||
"Create Workspace trust",
|
||||
"Replace trusted key",
|
||||
"Reactivate with this key",
|
||||
"Confirm current fingerprint",
|
||||
"Revoke Workspace trust",
|
||||
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.",
|
||||
"RuntimeTrustConflictError",
|
||||
"await reloadAuthority()",
|
||||
"busyAction !== null",
|
||||
"Workdirs",
|
||||
"Recent trust audit",
|
||||
]
|
||||
) {
|
||||
assert(page.includes(token), `Runtime detail should include ${token}`);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("Runtime detail uses flat sections instead of nested cards", async () => {
|
||||
const [page, css] = await Promise.all([
|
||||
Deno.readTextFile(
|
||||
new URL(
|
||||
"../src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte",
|
||||
import.meta.url,
|
||||
),
|
||||
),
|
||||
Deno.readTextFile(
|
||||
new URL("../src/lib/workspace/styles/settings.css", import.meta.url),
|
||||
),
|
||||
]);
|
||||
|
||||
assert(
|
||||
!page.includes('class="card"') && !page.includes("settings-card"),
|
||||
"Runtime detail should not add card nesting",
|
||||
);
|
||||
assert(
|
||||
css.includes(".runtime-detail-section") &&
|
||||
css.includes("border-top: 1px solid var(--line)"),
|
||||
"Runtime detail hierarchy should use flat section separators",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import {
|
||||
parseRuntimeTrustConflict,
|
||||
parseWorkspaceRuntimeDetail,
|
||||
parseWorkspaceRuntimeList,
|
||||
putRuntimeTrustKey,
|
||||
RuntimeTrustConflictError,
|
||||
} from "../src/lib/workspace/api/runtime-management.ts";
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
function assertThrows(operation: () => unknown, expected: string): void {
|
||||
try {
|
||||
operation();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (message.includes(expected)) return;
|
||||
throw new Error(
|
||||
`expected error containing ${expected}, received ${message}`,
|
||||
);
|
||||
}
|
||||
throw new Error("expected operation to throw");
|
||||
}
|
||||
|
||||
function runtime() {
|
||||
return {
|
||||
management: {
|
||||
built_in: false,
|
||||
config_managed: true,
|
||||
removable: false,
|
||||
endpoint_configured: true,
|
||||
token_ref_configured: false,
|
||||
},
|
||||
runtime_id: "arcadia",
|
||||
label: "Arcadia",
|
||||
kind: "remote",
|
||||
status: "started",
|
||||
source: {
|
||||
kind: "remote_http",
|
||||
status: "active",
|
||||
identity_authority: "server_runtime_configuration",
|
||||
note: "Configured by Server authority",
|
||||
},
|
||||
host_ids: ["host-a"],
|
||||
worker_creation_available: true,
|
||||
os: "linux",
|
||||
arch: "x86_64",
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
function detail() {
|
||||
return {
|
||||
workspace_id: "workspace-a",
|
||||
runtime: runtime(),
|
||||
endpoint: "https://runtime.example.test",
|
||||
trust_key: {
|
||||
status: "active",
|
||||
public_key: "ssh-ed25519 AAAA-test",
|
||||
fingerprint: "SHA256:current",
|
||||
revision: 3,
|
||||
created_at: "2026-09-01T12:00:00Z",
|
||||
updated_at: "2026-09-01T13:00:00Z",
|
||||
revoked_at: null,
|
||||
},
|
||||
recent_audit: [{
|
||||
action: "created",
|
||||
actor_account_id: "account-a",
|
||||
old_fingerprint: null,
|
||||
new_fingerprint: "SHA256:current",
|
||||
revision: 3,
|
||||
at: "2026-09-01T13:00:00Z",
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes", () => {
|
||||
const list = parseWorkspaceRuntimeList({
|
||||
workspace_id: "workspace-a",
|
||||
limit: 200,
|
||||
items: [runtime()],
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
});
|
||||
assert(
|
||||
list.items[0]?.runtime_id === "arcadia",
|
||||
"Runtime ID was not preserved",
|
||||
);
|
||||
|
||||
const parsed = parseWorkspaceRuntimeDetail(detail());
|
||||
assert(
|
||||
parsed.trust_key.revision === 3,
|
||||
"revision was not preserved as a safe integer",
|
||||
);
|
||||
assert(
|
||||
parsed.recent_audit[0]?.revision === 3,
|
||||
"audit revision was not normalized",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }),
|
||||
"head_tree is not part",
|
||||
);
|
||||
|
||||
const futureSource = structuredClone(detail());
|
||||
futureSource.runtime.source.kind = "future_transport";
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(futureSource),
|
||||
"contains an unknown enum value",
|
||||
);
|
||||
|
||||
assertThrows(
|
||||
() =>
|
||||
parseRuntimeTrustConflict({
|
||||
error: "future_conflict",
|
||||
message: "conflict",
|
||||
current_revision: 4,
|
||||
current_fingerprint: "SHA256:new",
|
||||
}),
|
||||
"contains an unknown enum value",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime validators reject unsafe revisions and bounded collection overflow", () => {
|
||||
const unsafeRevision = structuredClone(detail());
|
||||
unsafeRevision.trust_key.revision = Number.MAX_SAFE_INTEGER + 1;
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(unsafeRevision),
|
||||
"must be a safe integer",
|
||||
);
|
||||
|
||||
const tooMuchAudit = structuredClone(detail());
|
||||
tooMuchAudit.recent_audit = Array.from(
|
||||
{ length: 21 },
|
||||
() => structuredClone(detail().recent_audit[0]),
|
||||
);
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(tooMuchAudit),
|
||||
"must contain at most 20 items",
|
||||
);
|
||||
|
||||
const tooManyItems = Array.from({ length: 201 }, () => runtime());
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWorkspaceRuntimeList({
|
||||
workspace_id: "workspace-a",
|
||||
limit: 200,
|
||||
items: tooManyItems,
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
}),
|
||||
"must contain at most 200 items",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Runtime detail rejects unbounded strings and incoherent trust state", () => {
|
||||
const largeKey = structuredClone(detail());
|
||||
largeKey.trust_key.public_key = "x".repeat(16 * 1024 + 1);
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(largeKey),
|
||||
"must be at most 16384 UTF-8 bytes",
|
||||
);
|
||||
|
||||
const activeWithoutFingerprint = structuredClone(detail()) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
(activeWithoutFingerprint.trust_key as Record<string, unknown>).fingerprint =
|
||||
null;
|
||||
assertThrows(
|
||||
() => parseWorkspaceRuntimeDetail(activeWithoutFingerprint),
|
||||
"must include fingerprint",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user