28 Commits
Author SHA1 Message Date
Hare 4a89c04732 fix: allow corrupted workdir removal 2026-09-12 01:43:37 +09:00
Hare ec5a403ec6 feat: replace linked worktrees with runtime clones 2026-09-12 01:43:21 +09:00
Hare f6ce1df766 feat: support workspace-managed SSH repository access 2026-09-11 22:55:06 +09:00
Hare 9d7ddcc04a fix: enforce Workspace Runtime binding lifecycle 2026-09-09 13:20:00 +09:00
Hare 3df611636b feat: complete Workspace Runtime management flow 2026-09-09 09:38:26 +09:00
Hare d0999326bd fix: use Backend audience for embedded Runtime requests 2026-09-09 09:38:26 +09:00
Hare 6fbc65476c fix: migrate standalone Worker manifest snapshots 2026-09-09 01:22:59 +09:00
Hare fcc7d79d80 feat: authorize scoped symlink paths lexically 2026-09-09 00:51:21 +09:00
Hare 18fd6a1f5e fix: restore remote Runtime management contracts 2026-09-09 00:26:04 +09:00
Hare a072562034 chore: merge develop into hare/develop 2026-09-08 13:04:10 +09:00
Hare 2b4a2bc688 fix: fail closed on missing workspace capability 2026-09-08 12:40:04 +09:00
Hare 3344d9f8b2 refactor: remove server-global runtime trust 2026-09-08 12:19:39 +09:00
Hare fae36d220d fix: complete Runtime verification cutover 2026-09-08 10:11:18 +09:00
Hare 7b6a84a550 feat: project Runtime verification state 2026-09-08 09:22:44 +09:00
Hare f29c343879 feat: verify Workspace-signed Runtime bindings 2026-09-08 08:13:58 +09:00
Hare f5e9f49a13 fix: bind Runtime WebSockets to egress policy 2026-09-08 05:29:53 +09:00
Hare 73a35599d2 fix: complete configured Runtime onboarding 2026-09-08 05:15:14 +09:00
Hare 5080d7860e fix: preserve Runtime binding trust boundaries 2026-09-08 04:54:23 +09:00
Hare 7fb1d4056c feat: add manual Runtime trust setup UI 2026-09-08 04:22:45 +09:00
Hare 243a081874 feat: add configured Workspace Runtime bindings 2026-09-08 04:22:32 +09:00
Hare 04924cf796 fix: bound Runtime issuer trust surfaces 2026-09-08 02:31:58 +09:00
Hare 8f0917b8bc docs: remove obsolete local profile override guidance 2026-09-08 02:13:26 +09:00
Hare d4ad46127a feat: add GPT-6 Astra model catalog entry 2026-09-08 02:13:26 +09:00
Hare fba5ecf54c fix: harden Workspace issuer bootstrap 2026-09-08 02:00:19 +09:00
Hare e035df9e7b feat: add Runtime Workspace issuer trust 2026-09-08 01:54:10 +09:00
Hare 3baf0b6358 feat: integrate Workspace signing identity authority 2026-09-08 00:48:45 +09:00
Hare 4de04e42b5 fix: harden identity publication recovery 2026-09-08 00:30:38 +09:00
Hare ebec98a14c feat: add Workspace signing identity authority 2026-09-07 23:52:20 +09:00
93 changed files with 15366 additions and 4112 deletions
Generated
+24
View File
@@ -5086,8 +5086,12 @@ checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
dependencies = [
"futures-util",
"log",
"rustls",
"rustls-pki-types",
"tokio",
"tokio-rustls",
"tungstenite 0.29.0",
"webpki-roots 0.26.11",
]
[[package]]
@@ -5395,6 +5399,8 @@ dependencies = [
"httparse",
"log",
"rand 0.9.4",
"rustls",
"rustls-pki-types",
"sha1",
"thiserror 2.0.18",
]
@@ -6133,6 +6139,24 @@ dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "weezl"
version = "0.1.12"
+1 -1
View File
@@ -913,7 +913,7 @@ mod tests {
"working_directory": {
"working_directory_id": "wd-1",
"repository_key": "main",
"materializer_kind": "local_git_worktree",
"materializer_kind": "runtime_git_clone",
"status": "active",
"occupied_by": {
"runtime_id": "arcadia",
+4 -16
View File
@@ -12,10 +12,10 @@ use workspace_api::{
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse,
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource,
ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
};
use crate::{BackendApiClient, BackendWorkspaceClientError};
@@ -266,18 +266,6 @@ impl BackendWorkspaceProductClient {
))
}
pub fn put_runtime_trust_key(
&self,
runtime_id: &str,
request: &PutRuntimeTrustKeyRequest,
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
self.send_json(
Method::PUT,
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
Some(request),
)
}
pub fn revoke_runtime_trust_key(
&self,
runtime_id: &str,
+4 -12
View File
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use globset::Glob;
use ignore::WalkBuilder;
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult, direct_symlink};
use crate::{FsAccessPolicy, FsError, FsPath, GlobRequest, GlobResult};
/// Execute a bounded glob entirely inside the provider process.
pub fn run_glob(
@@ -18,21 +18,13 @@ pub fn run_glob(
if !access.is_readable(base) {
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str())));
}
if let Some(info) = direct_symlink(base)
&& info.target_exists
&& info.resolved_path.is_dir()
{
return Err(FsError::SymlinkDirectoryNotTraversed {
tool: "Glob",
path: PathBuf::from(request.path.as_str()),
target: PathBuf::from("<provider-internal target>"),
});
}
let matcher = Glob::new(&request.pattern)
.map_err(|error| FsError::InvalidGlob(error.to_string()))?
.compile_matcher();
let mut matches = Vec::new();
for entry in WalkBuilder::new(base).hidden(false).build().flatten() {
let mut walker = WalkBuilder::new(base);
walker.hidden(false).follow_links(false);
for entry in walker.build().flatten() {
let path = entry.path();
if !path.is_file() || !access.is_readable(path) {
continue;
+26 -8
View File
@@ -477,13 +477,14 @@ mod tests {
#[cfg(unix)]
#[test]
fn grep_keeps_direct_symlink_directory_and_broken_path_guards() {
fn grep_traverses_a_direct_symlink_directory_and_rejects_a_broken_path() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone());
std::fs::create_dir(root.join("target-dir")).unwrap();
std::fs::write(root.join("target-dir/nested.rs"), "needle nested\n").unwrap();
std::fs::write(root.join("target-file.rs"), "needle file\n").unwrap();
symlink(root.join("target-file.rs"), root.join("file-link.rs")).unwrap();
symlink(root.join("target-dir"), root.join("directory-link")).unwrap();
@@ -501,18 +502,35 @@ mod tests {
assert_eq!(file_result.match_count, 1);
assert!(file_result.output.starts_with("file-link.rs\n"));
let directory_error = run_grep(
let directory_result = run_grep(
&root,
root.join("directory-link"),
request("directory-link"),
&readable,
)
.unwrap_err();
assert!(matches!(
directory_error,
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. }
if path == root.join("directory-link")
));
.unwrap();
assert_eq!(directory_result.match_count, 1);
assert!(
directory_result
.output
.starts_with("directory-link/nested.rs\n")
);
let glob_result = run_glob(
&root,
&root.join("directory-link"),
GlobRequest {
pattern: "**/*.rs".to_string(),
path: FsPath::new("directory-link").unwrap(),
limit: 10,
},
&readable,
)
.unwrap();
assert_eq!(
glob_result.paths,
vec![FsPath::new("directory-link/nested.rs").unwrap()]
);
let broken_error = run_grep(
&root,
+9 -8
View File
@@ -45,7 +45,7 @@ pub fn run_read(
) -> Result<ReadResult, FsError> {
let logical = request.path;
let path = resolve(root, &logical)?;
let path = require_access(&path, &logical, access, false)?;
let path = require_access(&path, &logical, access, false, false)?;
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
@@ -99,7 +99,7 @@ pub fn run_write(
let path = resolve(root, &logical)?;
let created = !path.exists();
if path.exists() {
let target = require_access(&path, &logical, access, true)?;
let target = require_access(&path, &logical, access, true, false)?;
let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
@@ -117,7 +117,7 @@ pub fn run_write(
FsError::InvalidArgument(format!("{} has no parent", logical.as_str()))
})?;
let parent_logical = logical_parent(&logical);
require_access(parent, &parent_logical, access, true)?;
require_access(parent, &parent_logical, access, true, true)?;
atomic_write(&path, &request.content, &logical)?;
}
Ok(WriteResult {
@@ -133,7 +133,7 @@ pub fn run_edit(
) -> Result<EditResult, FsError> {
let logical = request.path;
let path = resolve(root, &logical)?;
let target = require_access(&path, &logical, access, true)?;
let target = require_access(&path, &logical, access, true, false)?;
let bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
let actual_hash = hash_bytes(&bytes);
if actual_hash != request.expected_hash {
@@ -173,7 +173,7 @@ pub fn run_list(
) -> Result<ListResult, FsError> {
let logical = request.path;
let path = resolve(root, &logical)?;
let path = require_access(&path, &logical, access, false)?;
let path = require_access(&path, &logical, access, false, true)?;
let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if !metadata.is_dir() {
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
@@ -247,6 +247,7 @@ fn require_access(
logical: &FsPath,
access: &dyn FsAccessPolicy,
write: bool,
allow_symlink_directory: bool,
) -> Result<PathBuf, FsError> {
if let Some(info) = direct_symlink(path) {
if !info.target_exists {
@@ -257,9 +258,9 @@ fn require_access(
});
}
let allowed = if write {
access.is_writable(&info.resolved_path)
access.is_writable(path)
} else {
access.is_readable(&info.resolved_path)
access.is_readable(path)
};
if !allowed {
return Err(FsError::SymlinkOutOfScope {
@@ -268,7 +269,7 @@ fn require_access(
required_permission: if write { "write" } else { "read" },
});
}
if write && info.resolved_path.is_dir() {
if !allow_symlink_directory && info.resolved_path.is_dir() {
return Err(FsError::SymlinkTargetIsDirectory {
path: PathBuf::from(logical.as_str()),
target: PathBuf::from("<provider-internal target>"),
-10
View File
@@ -259,16 +259,6 @@ pub fn run_grep(
base.display()
)));
}
if base_meta.is_dir()
&& let Some(info) = symlink.as_ref()
{
return Err(FsError::SymlinkDirectoryNotTraversed {
tool: "Grep",
path: base.clone(),
target: info.resolved_path.clone(),
});
}
let filter_base = if base_meta.is_file() { root } else { &base };
let types = build_types(p.file_type.as_deref())?;
let overrides = build_overrides(filter_base, p.glob.as_deref())?;
+47 -36
View File
@@ -3,11 +3,11 @@
//! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
//! rule `target` must already be an absolute path — per-layer path
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
//! All rule `target` paths inside the [`Scope`] are canonicalised (where
//! possible) so access checks are pure path comparisons.
//! All rule `target` paths inside the [`Scope`] are normalized lexically so
//! access authority follows the path presented through the Workdir, not a
//! symbolic-link target outside that logical tree.
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::path::{Component, Path, PathBuf};
use std::sync::{Arc, Mutex};
use arc_swap::{ArcSwap, Guard};
@@ -26,7 +26,7 @@ pub struct Scope {
#[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedRule {
/// Absolute, canonicalized-or-normalized target directory/file.
/// Absolute, lexically normalized target directory/file.
target: PathBuf,
permission: Permission,
recursive: bool,
@@ -201,9 +201,14 @@ impl Scope {
}
/// Convenience constructor for tests and simple setups: a single
/// recursive `allow(Write)` rule rooted at `root`.
/// recursive `allow(Write)` rule rooted at the lexical path `root`.
pub fn writable(root: impl AsRef<Path>) -> std::io::Result<Self> {
let root = root.as_ref().canonicalize()?;
let root = normalize_path(root.as_ref()).ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"scope root must be an absolute path without root traversal",
)
})?;
Ok(Self {
allow: vec![ResolvedRule {
target: root,
@@ -214,8 +219,7 @@ impl Scope {
})
}
/// Resolve one rule target with the same symlink and missing-tail semantics
/// used by scope matching.
/// Return one rule's lexically normalized target without resolving symlinks.
pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> {
Ok(resolve_rule(rule)?.target)
}
@@ -244,7 +248,7 @@ impl Scope {
/// Returns `None` when `path` is outside every allow rule, or when
/// deny rules have knocked it below `Read`.
pub fn permission_at(&self, path: &Path) -> Option<Permission> {
let resolved = resolve_path(path)?;
let resolved = normalize_path(path)?;
let mut effective: Option<Permission> = None;
for rule in &self.allow {
if rule.matches(&resolved) {
@@ -523,7 +527,7 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
if !rule.target.is_absolute() {
return Err(ScopeError::RelativeTarget(rule.target.clone()));
}
let target = resolve_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
let target = normalize_path(&rule.target).ok_or_else(|| ScopeError::ResolveTarget {
path: rule.target.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"),
})?;
@@ -534,37 +538,27 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
})
}
/// Convert `path` to an absolute form suitable for prefix comparison.
///
/// Tries `canonicalize` on the full path first (resolves symlinks). If
/// the path doesn't exist yet, climbs to the closest existing ancestor,
/// canonicalizes it, then rejoins the missing tail. Returns `None` for
/// relative inputs that have no existing ancestor to anchor against.
fn resolve_path(path: &Path) -> Option<PathBuf> {
/// Normalize an absolute path for lexical scope comparison without consulting
/// filesystem metadata or resolving symbolic links.
fn normalize_path(path: &Path) -> Option<PathBuf> {
if !path.is_absolute() {
return None;
}
if let Ok(canonical) = path.canonicalize() {
return Some(canonical);
}
let mut tail: Vec<OsString> = Vec::new();
let mut cur = path.to_path_buf();
loop {
if let Ok(canonical) = cur.canonicalize() {
let mut out = canonical;
for segment in tail.iter().rev() {
out.push(segment);
}
return Some(out);
}
let name = cur.file_name()?.to_os_string();
tail.push(name);
let parent = cur.parent()?.to_path_buf();
if parent == cur {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
Component::RootDir => normalized.push(component.as_os_str()),
Component::CurDir => {}
Component::ParentDir => {
if !normalized.pop() {
return None;
}
cur = parent;
}
Component::Normal(part) => normalized.push(part),
}
}
normalized.is_absolute().then_some(normalized)
}
#[cfg(test)]
@@ -805,6 +799,23 @@ mod tests {
assert!(!scope.is_readable(&traversal));
}
#[cfg(unix)]
#[test]
fn scope_authorizes_symlink_paths_lexically_without_authorizing_targets() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("outside.txt"), "visible through link").unwrap();
symlink(outside.path(), dir.path().join("external")).unwrap();
let scope = Scope::writable(dir.path()).unwrap();
assert!(scope.is_readable(&dir.path().join("external/outside.txt")));
assert!(scope.is_writable(&dir.path().join("external/new.txt")));
assert!(!scope.is_readable(&outside.path().join("outside.txt")));
assert!(!scope.is_writable(&outside.path().join("new.txt")));
}
#[test]
fn summary_lists_readable_and_writable() {
let dir = TempDir::new().unwrap();
+111 -6
View File
@@ -191,8 +191,7 @@ impl StandaloneWorkerStore {
StandaloneStoreError::Io(error)
}
})?;
let record: StandaloneWorkerRecord = serde_json::from_slice(&bytes)
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
let record = decode_worker_record(id, &bytes)?;
if record.schema_version > SCHEMA_VERSION {
return Err(StandaloneStoreError::NewerSchema {
id,
@@ -408,7 +407,7 @@ impl StandaloneWorkerStore {
.create_new(true)
.open(&temporary)
.map_err(StandaloneStoreError::Io)?;
serde_json::to_writer_pretty(&mut file, next).map_err(StandaloneStoreError::Json)?;
write_worker_record(&mut file, next)?;
file.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
file.sync_all().map_err(StandaloneStoreError::Io)?;
fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
@@ -428,8 +427,7 @@ impl StandaloneWorkerStore {
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let bytes =
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
serde_json::from_slice(&bytes)
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
decode_worker_record(id, &bytes)
}
fn worker_dir(&self, id: WorkerId) -> PathBuf {
@@ -634,6 +632,50 @@ fn observe_process(pid: u32) -> ProcessObservation {
}
}
fn decode_worker_record(
id: WorkerId,
bytes: &[u8],
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let decode = || -> Result<StandaloneWorkerRecord, serde_json::Error> {
let mut snapshot: serde_json::Value = serde_json::from_slice(bytes)?;
let object = snapshot.as_object_mut().ok_or_else(|| {
serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData,
"standalone Worker record must be an object",
))
})?;
let persisted_manifest = object.remove("manifest").ok_or_else(|| {
serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData,
"standalone Worker record is missing manifest",
))
})?;
let manifest = manifest::read_persisted_worker_manifest_snapshot(persisted_manifest)?;
object.insert("manifest".to_string(), serde_json::to_value(manifest)?);
serde_json::from_value(snapshot)
};
decode().map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
}
fn write_worker_record(
writer: &mut impl Write,
record: &StandaloneWorkerRecord,
) -> Result<(), StandaloneStoreError> {
let mut snapshot = serde_json::to_value(record).map_err(StandaloneStoreError::Json)?;
let object = snapshot.as_object_mut().ok_or_else(|| {
StandaloneStoreError::Json(serde_json::Error::io(io::Error::new(
io::ErrorKind::InvalidData,
"standalone Worker record must be an object",
)))
})?;
object.insert(
"manifest".to_string(),
manifest::write_persisted_worker_manifest_snapshot(&record.manifest)
.map_err(StandaloneStoreError::Json)?,
);
serde_json::to_writer_pretty(writer, &snapshot).map_err(StandaloneStoreError::Json)
}
fn now_unix_ms() -> Result<u64, StandaloneStoreError> {
let duration = SystemTime::now()
.duration_since(UNIX_EPOCH)
@@ -709,7 +751,70 @@ pub enum StandaloneStoreError {
#[cfg(test)]
mod tests {
use super::{LeaseLiveness, ProcessObservation, classify_lease_liveness};
use super::*;
fn test_manifest() -> WorkerManifest {
WorkerManifest::from_toml(
r#"
[worker]
name = "standalone-test"
[model]
scheme = "anthropic"
model_id = "claude-sonnet-4-20250514"
[engine]
[[scope.allow]]
target = "/tmp"
permission = "write"
"#,
)
.unwrap()
}
#[test]
fn standalone_record_uses_versioned_manifest_adapter_for_legacy_memory() {
let worker_id = "01a05782-d5dd-78f1-b9cd-ce37535bdb9d".parse().unwrap();
let manifest = test_manifest();
let record = StandaloneWorkerRecord {
schema_version: SCHEMA_VERSION,
revision: 6,
worker_id,
worker_name: manifest.worker.name.clone(),
storage_key: "standalone-test".to_string(),
cwd: StandaloneCwdIdentity {
canonical_path: PathBuf::from("/tmp"),
device: None,
inode: None,
},
manifest,
active_session_id: "01a05782-d5dd-78f1-b9cd-ce37535bdb9e".parse().unwrap(),
active_segment_id: None,
status: StandaloneWorkerStatus::Stopped,
created_at_unix_ms: 1,
updated_at_unix_ms: 2,
shutdown_reason: None,
};
let mut legacy = serde_json::to_value(&record).unwrap();
legacy["manifest"]["feature"]["memory"] = serde_json::json!({
"enabled": false,
"staging": false,
});
let decoded =
decode_worker_record(worker_id, &serde_json::to_vec(&legacy).unwrap()).unwrap();
assert!(!decoded.manifest.feature.memory.profile.enabled);
let mut persisted = Vec::new();
write_worker_record(&mut persisted, &decoded).unwrap();
let persisted: serde_json::Value = serde_json::from_slice(&persisted).unwrap();
assert_eq!(persisted["manifest"]["schema_version"], 2);
assert_eq!(
persisted["manifest"]["manifest"]["feature"]["memory"]["profile"]["enabled"],
false
);
}
#[test]
fn lease_liveness_requires_positive_live_or_stale_evidence() {
+2 -2
View File
@@ -475,7 +475,7 @@ mod tests {
serde_json::from_value(serde_json::json!({
"working_directory_id": "001a06a9f0202000000",
"repository_key": "main",
"materializer_kind": "local_git_worktree",
"materializer_kind": "runtime_git_clone",
"status": "active",
"cleanliness": "clean"
}))
@@ -518,7 +518,7 @@ mod tests {
serde_json::from_value(serde_json::json!({
"working_directory_id": "workdir-1",
"repository_key": "main",
"materializer_kind": "local_git_worktree",
"materializer_kind": "runtime_git_clone",
"status": "active"
}))
.unwrap(),
+1
View File
@@ -14,6 +14,7 @@ fs-operation.workspace = true
manifest.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true
tempfile.workspace = true
thiserror.workspace = true
+26 -7
View File
@@ -293,7 +293,12 @@ mod client {
/// implementations can mint short-lived capability tokens without making a
/// Worker-bound session expire with the token used to open it.
pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync {
fn bearer_token(&self) -> Result<String, WorkdirError>;
fn bearer_token(
&self,
method: &str,
path_and_query: &str,
body: &[u8],
) -> Result<String, WorkdirError>;
}
struct FixedBearerToken(Arc<str>);
@@ -305,7 +310,12 @@ mod client {
}
impl WorkdirHttpAuthorization for FixedBearerToken {
fn bearer_token(&self) -> Result<String, WorkdirError> {
fn bearer_token(
&self,
_method: &str,
_path_and_query: &str,
_body: &[u8],
) -> Result<String, WorkdirError> {
Ok(self.0.to_string())
}
}
@@ -354,10 +364,14 @@ mod client {
&base_url,
&["v1", "working-directories", workdir_id.as_str(), "sessions"],
)?;
let body = serde_json::to_vec(&request)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
let token = authorization.bearer_token("POST", url.path(), &body)?;
let response = client
.post(url)
.bearer_auth(authorization.bearer_token()?)
.json(&request)
.bearer_auth(token)
.header("content-type", "application/json")
.body(body)
.send()
.await
.map_err(http_unavailable)?;
@@ -401,11 +415,15 @@ mod client {
],
)?;
let operation = WorkdirSessionOperationRequest { operation };
let body = serde_json::to_vec(&operation)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
let token = self.authorization.bearer_token("POST", url.path(), &body)?;
let response = self
.client
.post(url)
.bearer_auth(self.authorization.bearer_token()?)
.json(&operation)
.bearer_auth(token)
.header("content-type", "application/json")
.body(body)
.send()
.await
.map_err(http_unavailable)?;
@@ -543,10 +561,11 @@ mod client {
&self.base_url,
&["v1", "workdir-sessions", self.session_id.as_str()],
)?;
let token = self.authorization.bearer_token("DELETE", url.path(), &[])?;
let response = self
.client
.delete(url)
.bearer_auth(self.authorization.bearer_token()?)
.bearer_auth(token)
.send()
.await
.map_err(http_unavailable)?;
+73 -18
View File
@@ -1635,7 +1635,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn read_bytes_reports_symlink_target_outside_scope() {
fn read_bytes_allows_logical_symlink_path_with_target_outside_scope() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
@@ -1646,15 +1646,7 @@ mod tests {
symlink(&target, &link).unwrap();
let fs = make_fs(&dir);
let err = fs.read_bytes(&link).unwrap_err();
assert!(
matches!(
err,
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "read" }
if path == &link && err_target == &target.canonicalize().unwrap()
),
"expected symlink out-of-scope diagnostic, got {err:?}"
);
assert_eq!(fs.read_bytes(&link).unwrap(), b"secret");
}
#[cfg(unix)]
@@ -1746,7 +1738,7 @@ mod tests {
#[cfg(unix)]
#[test]
fn write_reports_symlink_target_outside_scope() {
fn write_allows_logical_symlink_path_with_target_outside_scope() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
@@ -1757,14 +1749,13 @@ mod tests {
symlink(&target, &link).unwrap();
let fs = make_fs(&dir);
let err = fs.write(&link, b"new").unwrap_err();
fs.write(&link, b"new").unwrap();
assert_eq!(fs::read(&target).unwrap(), b"new");
assert!(
matches!(
err,
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" }
if path == &link && err_target == &target.canonicalize().unwrap()
),
"expected write symlink out-of-scope diagnostic, got {err:?}"
fs::symlink_metadata(&link)
.unwrap()
.file_type()
.is_symlink()
);
}
@@ -1942,6 +1933,70 @@ mod tests {
));
}
#[cfg(unix)]
#[tokio::test]
async fn provider_uses_logical_paths_through_symlinked_directories() {
use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap();
let outside = TempDir::new().unwrap();
std::fs::write(outside.path().join("worker.json"), "scope-needle\n").unwrap();
symlink(outside.path(), dir.path().join("yoi.local")).unwrap();
let workdir = make_fs(&dir);
let read = WorkdirSession::read(
&workdir,
ReadRequest {
path: WorkdirPath::new("yoi.local/worker.json").unwrap(),
offset: 0,
limit: 100,
max_bytes: 1024,
},
)
.await
.unwrap();
assert_eq!(read.bytes, b"scope-needle\n");
let glob = WorkdirSession::glob(
&workdir,
GlobRequest {
pattern: "**/*.json".into(),
path: WorkdirPath::new("yoi.local").unwrap(),
limit: 10,
},
)
.await
.unwrap();
assert_eq!(
glob.paths,
[WorkdirPath::new("yoi.local/worker.json").unwrap()]
);
let grep = WorkdirSession::grep(
&workdir,
GrepRequest {
pattern: "scope-needle".into(),
path: WorkdirPath::new("yoi.local").unwrap(),
glob: Some("*.json".into()),
file_type: None,
case_insensitive: false,
before_context: 0,
after_context: 0,
multiline: false,
output_mode: crate::GrepOutputMode::Content,
limit: 10,
offset: 0,
},
)
.await
.unwrap();
assert_eq!(grep.match_count, 1);
assert!(grep.output.contains("yoi.local/worker.json"));
assert!(
!workdir
.scope()
.is_readable(&outside.path().join("worker.json"))
);
}
#[tokio::test]
async fn provider_executes_glob_grep_and_command_at_the_materialization() {
let dir = TempDir::new().unwrap();
+32 -72
View File
@@ -492,51 +492,9 @@ impl ScopedWorkdirSession {
}
}
async fn ensure_source_path_has_no_symlink(&self, path: &FsPath) -> Result<(), WorkdirError> {
let mut current = String::new();
for component in Path::new(path.as_str()).components() {
let component = component.as_os_str().to_string_lossy();
if component.is_empty() || component == "." {
continue;
}
if !current.is_empty() {
current.push('/');
}
current.push_str(&component);
let current = FsPath::new(&current).map_err(|error| {
WorkdirError::Denied(format!("invalid scoped Workdir path: {error}"))
})?;
match self.source.stat(StatRequest { path: current }).await {
Ok(result) if result.kind == fs_operation::EntryKind::Symlink => {
return Err(WorkdirError::Denied(format!(
"scoped Workdir path `{path}` traverses a symlink"
)));
}
Ok(_) => {}
Err(WorkdirError::NotFound(_)) => break,
Err(error) => return Err(error),
}
}
Ok(())
}
async fn ensure_scope_targets_do_not_traverse_symlinks(
&self,
rules: &[WorkdirToolScopeRule],
) -> Result<(), WorkdirError> {
for rule in rules {
self.ensure_source_path_has_no_symlink(&rule.target).await?;
}
Ok(())
}
async fn resolve_operation_path(&self, path: &FsPath) -> Result<FsPath, WorkdirError> {
fn resolve_operation_path(&self, path: &FsPath) -> Result<FsPath, WorkdirError> {
self.ensure_active()?;
let resolved = self.resolve_path(path)?;
if self.scope.is_some() {
self.ensure_source_path_has_no_symlink(&resolved).await?;
}
Ok(resolved)
self.resolve_path(path)
}
fn validate_scope(
@@ -624,8 +582,6 @@ impl ScopedWorkdirSession {
request.cwd
)));
}
self.ensure_scope_targets_do_not_traverse_symlinks(&request.rules)
.await?;
let validity = SessionValidity::child(self.validity.clone());
let cleanup_pending = Arc::new(AtomicBool::new(true));
let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed);
@@ -736,49 +692,49 @@ impl WorkdirSession for ScopedWorkdirSession {
}
async fn stat(&self, mut request: StatRequest) -> Result<StatResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?;
let path = self.resolve_operation_path(&request.path)?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path;
self.source.stat(request).await
}
async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?;
let path = self.resolve_operation_path(&request.path)?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path;
self.source.read(request).await
}
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?;
let path = self.resolve_operation_path(&request.path)?;
self.ensure_write(&path, WorkdirSessionCapability::Write)?;
request.path = path;
self.source.write(request).await
}
async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?;
let path = self.resolve_operation_path(&request.path)?;
self.ensure_write(&path, WorkdirSessionCapability::Edit)?;
request.path = path;
self.source.edit(request).await
}
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?;
let path = self.resolve_operation_path(&request.path)?;
self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path;
self.source.list(request).await
}
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?;
let path = self.resolve_operation_path(&request.path)?;
self.ensure_read(&path, WorkdirSessionCapability::Glob)?;
request.path = path;
self.source.glob(request).await
}
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> {
let path = self.resolve_operation_path(&request.path).await?;
let path = self.resolve_operation_path(&request.path)?;
self.ensure_read(&path, WorkdirSessionCapability::Grep)?;
request.path = path;
self.source.grep(request).await
@@ -1487,7 +1443,7 @@ mod tests {
#[cfg(unix)]
#[tokio::test]
async fn provider_scope_denies_read_through_symlink_outside_grant() {
async fn provider_scope_allows_read_through_its_logical_symlink_path() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
@@ -1501,16 +1457,12 @@ mod tests {
.await
.unwrap();
let result = child.read(read("link")).await;
assert!(
result.is_err(),
"symlink read escaped provider scope: {result:?}"
);
assert_eq!(child.read(read("link")).await.unwrap().bytes, b"hidden");
}
#[cfg(unix)]
#[tokio::test]
async fn provider_scope_denies_write_through_symlink_outside_grant() {
async fn provider_scope_allows_write_through_its_logical_symlink_path() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
@@ -1523,17 +1475,19 @@ mod tests {
.await
.unwrap();
let result = child.write(write("outside/new", "forbidden")).await;
assert!(
result.is_err(),
"symlink write escaped provider scope: {result:?}"
child
.write(write("outside/new", "through-logical-path"))
.await
.unwrap();
assert_eq!(
fs::read_to_string(root.path().join("secret/new")).unwrap(),
"through-logical-path"
);
assert!(!root.path().join("secret/new").exists());
}
#[cfg(unix)]
#[tokio::test]
async fn write_delegation_rejects_symlink_target_before_lease() {
async fn write_delegation_leases_the_logical_symlink_path() {
use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap();
@@ -1542,19 +1496,25 @@ mod tests {
symlink("../secret", root.path().join("granted/outside")).unwrap();
let parent = session(root.path());
assert!(matches!(
parent
let child = parent
.scope(request(
"granted/outside",
WorkdirToolScopePermission::Write
WorkdirToolScopePermission::Write,
))
.await,
Err(WorkdirError::Denied(_))
));
.await
.unwrap();
child
.write(write("from-child", "child-authoritative"))
.await
.unwrap();
parent
.write(write("secret/parent", "still-authoritative"))
.await
.unwrap();
assert_eq!(
fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
"child-authoritative"
);
}
#[tokio::test]
+1 -1
View File
@@ -39,7 +39,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature
ring.workspace = true
tar.workspace = true
thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
tokio = { workspace = true, features = ["net", "process", "rt", "sync", "time"] }
tracing.workspace = true
tracing-subscriber.workspace = true
toml.workspace = true
+87 -220
View File
@@ -2,6 +2,7 @@ use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::{SecureRandom, SystemRandom};
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::fmt;
@@ -9,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
const TOKEN_PREFIX: &str = "yoi-cap-v1";
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
@@ -68,6 +67,74 @@ pub enum RuntimeAuthError {
WrongMutationTarget,
}
pub(crate) struct SignedJsonToken<T> {
pub payload: String,
pub signature: Vec<u8>,
pub claims: T,
}
pub(crate) fn sign_json_token<T: Serialize>(
token_prefix: &str,
signing_input_prefix: &str,
signing_key: &Ed25519KeyPair,
claims: &T,
) -> Result<String, RuntimeAuthError> {
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?);
let signing_input = format!("{signing_input_prefix}{payload}");
let signature = signing_key.sign(signing_input.as_bytes());
Ok(format!(
"{token_prefix}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
pub(crate) fn decode_signed_json_token<T: DeserializeOwned>(
token: &str,
expected_prefix: &str,
) -> Result<SignedJsonToken<T>, RuntimeAuthError> {
let (prefix, payload, signature) = split_three_part_token(token)?;
if prefix != expected_prefix {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let signature = URL_SAFE_NO_PAD.decode(signature)?;
let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?;
Ok(SignedJsonToken {
payload: payload.to_string(),
signature,
claims,
})
}
pub(crate) fn verify_signed_json_token(
signing_input_prefix: &str,
payload: &str,
signature: &[u8],
public_key: &str,
) -> Result<(), RuntimeAuthError> {
let public_key = decode_public_key(public_key)?;
let signing_input = format!("{signing_input_prefix}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)
}
fn split_three_part_token(token: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = token.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
pub(crate) fn is_request_body_digest(value: &str) -> bool {
URL_SAFE_NO_PAD
.decode(value)
.is_ok_and(|decoded| decoded.len() == 32 && URL_SAFE_NO_PAD.encode(decoded) == value)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeIdentityMaterial {
pub identity_id: String,
@@ -95,21 +162,6 @@ impl RuntimeIdentityMaterial {
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedServerKey {
pub server_id: String,
pub public_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpAuthConfig {
pub runtime_id: String,
#[serde(default)]
pub trusted_servers: Vec<TrustedServerKey>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthContext {
pub server_id: String,
@@ -119,122 +171,6 @@ pub struct RuntimeAuthContext {
pub expires_at: u64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityClaims {
pub iss: String,
pub aud: String,
pub workspace_id: String,
pub permissions: Vec<String>,
pub exp: u64,
pub jti: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapabilityTokenSigner {
server_id: String,
private_key: String,
}
impl CapabilityTokenSigner {
pub fn new(server_id: impl Into<String>, private_key: impl Into<String>) -> Self {
Self {
server_id: server_id.into(),
private_key: private_key.into(),
}
}
pub fn server_id(&self) -> &str {
&self.server_id
}
pub fn sign(&self, claims: &CapabilityClaims) -> Result<String, RuntimeAuthError> {
if claims.iss != self.server_id {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
}
let private = decode_private_key(&self.private_key)?;
let pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let payload = serde_json::to_vec(claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
let signature = pair.sign(signing_input.as_bytes());
Ok(format!(
"{TOKEN_PREFIX}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
}
pub fn capability_claims(
server_id: impl Into<String>,
runtime_id: impl Into<String>,
workspace_id: impl Into<String>,
permissions: Vec<String>,
ttl_seconds: u64,
) -> Result<CapabilityClaims, RuntimeAuthError> {
let exp = unix_now_seconds().saturating_add(ttl_seconds);
Ok(CapabilityClaims {
iss: server_id.into(),
aud: runtime_id.into(),
workspace_id: workspace_id.into(),
permissions,
exp,
jti: new_token_id()?,
})
}
pub fn verify_capability_token(
config: &RuntimeHttpAuthConfig,
token: &str,
required_permission: Option<&str>,
now_seconds: u64,
) -> Result<RuntimeAuthContext, RuntimeAuthError> {
let (payload, signature) = split_token(token)?;
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
let claims: CapabilityClaims = serde_json::from_slice(&claims_json)?;
let Some(server) = config
.trusted_servers
.iter()
.find(|server| server.server_id == claims.iss)
else {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
};
let public_key = decode_public_key(&server.public_key)?;
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
if claims.aud != config.runtime_id {
return Err(RuntimeAuthError::WrongAudience {
expected: config.runtime_id.clone(),
actual: claims.aud,
});
}
if claims.exp < now_seconds {
return Err(RuntimeAuthError::Expired);
}
if claims.workspace_id.trim().is_empty() {
return Err(RuntimeAuthError::MissingWorkspaceScope);
}
if let Some(required) = required_permission {
if !claims
.permissions
.iter()
.any(|permission| permission == required)
{
return Err(RuntimeAuthError::MissingPermission(required.to_string()));
}
}
Ok(RuntimeAuthContext {
server_id: claims.iss,
workspace_id: claims.workspace_id,
permissions: claims.permissions,
token_id: claims.jti,
expires_at: claims.exp,
})
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeRequestSourceClaims {
pub iss: String,
@@ -323,28 +259,22 @@ impl RuntimeRequestSourceSigner {
exp: now_unix.saturating_add(ttl_seconds),
jti: new_token_id()?,
};
let payload = serde_json::to_vec(&claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let private = decode_private_key(&self.private_key)?;
let key_pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref());
Ok(format!(
"{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}"
))
sign_json_token(
RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
&key_pair,
&claims,
)
}
}
pub fn decode_runtime_request_source_claims(
proof: &str,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?;
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let payload = URL_SAFE_NO_PAD.decode(payload)?;
serde_json::from_slice(&payload).map_err(RuntimeAuthError::from)
Ok(decode_signed_json_token(proof, RUNTIME_REQUEST_SOURCE_PROOF_PREFIX)?.claims)
}
pub fn verify_runtime_request_source(
@@ -352,17 +282,17 @@ pub fn verify_runtime_request_source(
public_key: &str,
expected: &RuntimeRequestSourceExpectation<'_>,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?;
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let signature = URL_SAFE_NO_PAD.decode(signature)?;
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let public_key = decode_public_key(public_key)?;
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
let claims = decode_runtime_request_source_claims(proof)?;
let signed = decode_signed_json_token::<RuntimeRequestSourceClaims>(
proof,
RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
)?;
verify_signed_json_token(
RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
&signed.payload,
&signed.signature,
public_key,
)?;
let claims = signed.claims;
if claims.iss != expected.identity_id
|| claims.aud != expected.audience
|| claims.workspace_id != expected.workspace_id
@@ -380,17 +310,6 @@ pub fn verify_runtime_request_source(
Ok(claims)
}
fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = proof.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerMutationSourceClaims {
pub iss: String,
@@ -586,16 +505,6 @@ fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec<u8>), Ru
}
}
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
let mut parts = token.split('.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(prefix), Some(payload), Some(signature), None) if prefix == TOKEN_PREFIX => {
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
}
_ => Err(RuntimeAuthError::InvalidTokenFormat),
}
}
pub fn encode_public_key(bytes: &[u8]) -> String {
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
}
@@ -851,46 +760,4 @@ mod tests {
Err(RuntimeAuthError::Expired)
));
}
#[test]
fn capability_token_verifies_signature_audience_expiry_and_permission() {
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
let signer = CapabilityTokenSigner::new(&server.identity_id, &server.private_key);
let claims = CapabilityClaims {
iss: "server-main".to_string(),
aud: "runtime-main".to_string(),
workspace_id: "workspace-a".to_string(),
permissions: vec!["workers:list".to_string()],
exp: 100,
jti: "token-1".to_string(),
};
let token = signer.sign(&claims).unwrap();
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-main".to_string(),
trusted_servers: vec![TrustedServerKey {
server_id: "server-main".to_string(),
public_key: server.public_key.clone(),
display_name: None,
}],
};
let context = verify_capability_token(&auth, &token, Some("workers:list"), 99).unwrap();
assert_eq!(context.workspace_id, "workspace-a");
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:create"), 99),
Err(RuntimeAuthError::MissingPermission(permission)) if permission == "workers:create"
));
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:list"), 101),
Err(RuntimeAuthError::Expired)
));
let wrong_audience = RuntimeHttpAuthConfig {
runtime_id: "other-runtime".to_string(),
trusted_servers: auth.trusted_servers.clone(),
};
assert!(matches!(
verify_capability_token(&wrong_audience, &token, Some("workers:list"), 99),
Err(RuntimeAuthError::WrongAudience { .. })
));
}
}
+8 -5
View File
@@ -119,9 +119,16 @@ impl std::fmt::Debug for SensitiveString {
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshMaterializationAccess {
pub struct RepositorySshCredentialCandidate {
pub credential_id: String,
pub credential_revision: u64,
#[serde(skip, default)]
pub private_key: SensitiveString,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshMaterializationAccess {
pub credential_candidates: Vec<RepositorySshCredentialCandidate>,
pub host_trust_id: String,
pub host_trust_revision: u64,
pub access: workspace_api::RepositoryAccessMode,
@@ -131,8 +138,6 @@ pub struct RepositorySshMaterializationAccess {
pub repository_uri: String,
pub secret_resource: crate::resource::BackendResourceHandle,
#[serde(skip, default)]
pub private_key: SensitiveString,
#[serde(skip, default)]
pub known_hosts_entry: SensitiveString,
}
@@ -143,8 +148,6 @@ pub struct RepositoryMaterializationContext {
pub operation_id: String,
pub config_revision: u64,
pub config_projection_digest: String,
#[serde(default)]
pub cache_generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>,
}
+3
View File
@@ -287,6 +287,7 @@ pub enum WorkspaceConfigFetchResult {
pub enum WorkerExecutionSpawnResult {
Connected {
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>,
},
Rejected(WorkerExecutionResult),
@@ -296,10 +297,12 @@ pub enum WorkerExecutionSpawnResult {
impl WorkerExecutionSpawnResult {
pub fn connected(
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>,
) -> Self {
Self::Connected {
handle,
worker_state,
working_directory,
}
}
File diff suppressed because it is too large Load Diff
+2
View File
@@ -25,9 +25,11 @@ pub mod resource;
#[cfg(feature = "fs-store")]
pub mod retention;
mod runtime;
pub mod ssh_host_key_probe;
pub mod worker_backend;
pub mod worker_source;
pub mod working_directory;
pub mod workspace_issuer;
#[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
File diff suppressed because it is too large Load Diff
+94 -8
View File
@@ -13,16 +13,41 @@ pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
"application/vnd.yoi.repository-ssh-access+json";
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024;
pub const DEFAULT_REPOSITORY_SSH_ACCESS_MAX_BYTES: u64 = 64 * 1024;
pub const DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT: std::time::Duration =
std::time::Duration::from_secs(15);
#[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecretCandidate {
pub credential_id: String,
pub credential_revision: u64,
pub private_key: String,
}
impl Drop for RepositorySshAccessSecretCandidate {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.private_key);
}
}
impl std::fmt::Debug for RepositorySshAccessSecretCandidate {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RepositorySshAccessSecretCandidate")
.field("credential_id", &self.credential_id)
.field("credential_revision", &self.credential_revision)
.field("private_key", &"[REDACTED]")
.finish()
}
}
#[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecret {
pub private_key: String,
pub credential_candidates: Vec<RepositorySshAccessSecretCandidate>,
pub known_hosts_entry: String,
}
impl Drop for RepositorySshAccessSecret {
fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.private_key);
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry);
}
}
@@ -31,7 +56,7 @@ impl std::fmt::Debug for RepositorySshAccessSecret {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("RepositorySshAccessSecret")
.field("private_key", &"[REDACTED]")
.field("credential_candidates", &self.credential_candidates)
.field("known_hosts_entry", &"[REDACTED]")
.finish()
}
@@ -142,6 +167,8 @@ pub enum BackendResourceError {
Oversized { max_bytes: u64, actual_bytes: u64 },
#[error("backend resource content type mismatch: expected {expected}, got {actual}")]
ContentTypeMismatch { expected: String, actual: String },
#[error("backend resource fetch timed out")]
Timeout,
#[error("backend resource transport failed: {message}")]
Transport { message: String },
#[error("backend resource response is invalid: {message}")]
@@ -163,6 +190,7 @@ pub struct HttpBackendResourceClient {
bearer_token: Option<String>,
request_source_signer: Option<RuntimeRequestSourceSigner>,
request_source_audience: Option<String>,
request_timeout: std::time::Duration,
client: reqwest::Client,
}
@@ -174,10 +202,16 @@ impl HttpBackendResourceClient {
bearer_token,
request_source_signer: None,
request_source_audience: None,
request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT,
client: reqwest::Client::new(),
}
}
pub fn with_request_timeout(mut self, timeout: std::time::Duration) -> Self {
self.request_timeout = timeout;
self
}
pub fn with_runtime_request_source(
mut self,
identity: &RuntimeIdentityMaterial,
@@ -209,6 +243,7 @@ impl BackendResourceClient for HttpBackendResourceClient {
let mut builder = self
.client
.post(endpoint.clone())
.timeout(self.request_timeout)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.clone());
if let Some(signer) = self.request_source_signer.as_ref() {
@@ -239,11 +274,14 @@ impl BackendResourceClient for HttpBackendResourceClient {
} else {
builder
};
let response = builder
.send()
.await
.map_err(|err| BackendResourceError::Transport {
message: err.to_string(),
let response = builder.send().await.map_err(|error| {
if error.is_timeout() {
BackendResourceError::Timeout
} else {
BackendResourceError::Transport {
message: error.to_string(),
}
}
})?;
if response.status().is_success() {
response
@@ -382,6 +420,54 @@ mod tests {
}
}
#[cfg(feature = "http-server")]
#[tokio::test]
async fn http_backend_resource_fetch_has_a_bounded_timeout() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (stream, _) = listener.accept().await.unwrap();
futures::future::pending::<()>().await;
drop(stream);
});
let base_url = format!("http://{address}");
let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap();
let handle = handle_for(b"archive-bytes");
let client = HttpBackendResourceClient::new(format!("{base_url}/fetch"), None)
.with_request_timeout(std::time::Duration::from_millis(25))
.with_runtime_request_source(&identity, base_url);
let error = client
.fetch_resource(BackendResourceFetchRequest {
audit_correlation_id: handle.audit_correlation_id.clone(),
handle,
runtime_id: "runtime-test".to_string(),
worker_id: None,
})
.await
.unwrap_err();
server.abort();
assert_eq!(error, BackendResourceError::Timeout);
}
#[test]
fn repository_ssh_access_secret_debug_redacts_all_secret_values() {
let secret = RepositorySshAccessSecret {
credential_candidates: vec![RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 2,
private_key: "PRIVATE KEY secret bytes".to_string(),
}],
known_hosts_entry: "host key secret bytes".to_string(),
};
let debug = format!("{secret:?}");
assert!(debug.contains("credential-1"));
assert!(!debug.contains("secret bytes"));
assert_eq!(debug.matches("[REDACTED]").count(), 2);
}
#[test]
fn response_verification_detects_digest_mismatch() {
let bytes = b"archive-bytes";
+248 -33
View File
@@ -189,6 +189,23 @@ impl Runtime {
Ok(())
}
pub fn install_workspace_backend_resource_client(
&self,
workspace_id: impl Into<String>,
client: Arc<dyn BackendResourceClient>,
) -> Result<(), RuntimeError> {
let workspace_id = workspace_id.into();
if workspace_id.trim().is_empty() {
return Err(RuntimeError::InvalidRequest(
"Backend resource client Workspace id is empty".to_string(),
));
}
self.lock()?
.workspace_backend_resource_clients
.insert(workspace_id, BackendResourceClientRef(client));
Ok(())
}
/// Create or restore a filesystem-backed Runtime.
///
/// The store is scoped by `options.root`; if the directory already exists,
@@ -439,26 +456,51 @@ impl Runtime {
&self,
ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
) -> Result<(), RuntimeError> {
if !ssh.private_key.expose().is_empty() && !ssh.known_hosts_entry.expose().is_empty() {
if ssh.credential_candidates.is_empty() {
return Err(RuntimeError::InvalidRequest(
"Repository SSH access requires at least one credential candidate".to_string(),
));
}
if ssh
.credential_candidates
.iter()
.all(|candidate| !candidate.private_key.expose().is_empty())
&& !ssh.known_hosts_entry.expose().is_empty()
{
return Ok(());
}
let (client, runtime_id) = {
let state = self.lock()?;
let client = state.backend_resource_client.clone().ok_or_else(|| {
RuntimeError::InvalidRequest(
"Backend Repository access resource client is unavailable".to_string(),
)
let client = state
.workspace_backend_resource_clients
.get(&ssh.secret_resource.workspace_id)
.cloned()
.or_else(|| state.backend_resource_client.clone())
.ok_or_else(|| {
RuntimeError::InvalidRequest(format!(
"Backend Repository access resource client is unavailable for Workspace `{}`",
ssh.secret_resource.workspace_id
))
})?;
let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string())
})?;
(client, runtime_id)
};
tracing::info!(
target: "yoi::repository_access",
event = "repository_access_resource_fetch_started",
workspace_id = %ssh.secret_resource.workspace_id,
resource_id = %ssh.secret_resource.resource_id,
runtime_id = %runtime_id,
credential_candidate_count = ssh.credential_candidates.len(),
"fetching Repository SSH access resource from Workspace Backend"
);
let mut response = client
.0
.fetch_resource(BackendResourceFetchRequest {
handle: ssh.secret_resource.clone(),
runtime_id,
runtime_id: runtime_id.clone(),
worker_id: None,
audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(),
})
@@ -481,10 +523,40 @@ impl Runtime {
"Backend Repository SSH access resource payload was invalid".to_string(),
)
})?;
ssh.private_key =
if secret.credential_candidates.len() != ssh.credential_candidates.len()
|| secret
.credential_candidates
.iter()
.zip(&ssh.credential_candidates)
.any(|(secret, metadata)| {
secret.credential_id != metadata.credential_id
|| secret.credential_revision != metadata.credential_revision
})
{
return Err(RuntimeError::InvalidRequest(
"Backend Repository SSH access resource credential metadata was invalid"
.to_string(),
));
}
for (candidate, secret) in ssh
.credential_candidates
.iter_mut()
.zip(&mut secret.credential_candidates)
{
candidate.private_key =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key));
}
ssh.known_hosts_entry =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry));
tracing::info!(
target: "yoi::repository_access",
event = "repository_access_resource_fetch_succeeded",
workspace_id = %ssh.secret_resource.workspace_id,
resource_id = %ssh.secret_resource.resource_id,
runtime_id = %runtime_id,
credential_candidate_count = ssh.credential_candidates.len(),
"fetched Repository SSH access resource from Workspace Backend"
);
Ok(())
}
@@ -492,10 +564,22 @@ impl Runtime {
&self,
mut request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeError> {
let materialization_runtime_id = request.materialization.runtime_id.clone();
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string())
})?;
self.resolve_repository_access_resource(ssh).await?;
if let Err(error) = self.resolve_repository_access_resource(ssh).await {
tracing::warn!(
target: "yoi::repository_access",
event = "repository_access_resource_fetch_failed",
workspace_id = %ssh.secret_resource.workspace_id,
resource_id = %ssh.secret_resource.resource_id,
runtime_id = %materialization_runtime_id,
error = %error,
"failed to fetch Repository SSH access resource from Workspace Backend"
);
return Err(error);
}
self.authorize_working_directory_repository_access(request)
}
@@ -890,11 +974,12 @@ impl Runtime {
};
let spawn_result = backend.spawn_worker(spawn_request);
let (handle, working_directory) = match spawn_result {
let (handle, initial_worker_state, working_directory) = match spawn_result {
WorkerExecutionSpawnResult::Connected {
handle,
worker_state,
working_directory,
} => (handle, working_directory),
} => (handle, worker_state, working_directory),
WorkerExecutionSpawnResult::Rejected(result)
| WorkerExecutionSpawnResult::Errored(result) => {
self.rollback_failed_create(&worker_ref)?;
@@ -950,6 +1035,7 @@ impl Runtime {
let detail = match self.commit_created_worker(
&worker_ref,
handle.clone(),
initial_worker_state.clone(),
working_directory,
dispatch_result,
) {
@@ -968,6 +1054,7 @@ impl Runtime {
match self.commit_created_worker(
&worker_ref,
handle.clone(),
initial_worker_state,
working_directory,
WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn),
) {
@@ -1260,11 +1347,13 @@ impl Runtime {
match backend.restore_worker(request) {
WorkerExecutionSpawnResult::Connected {
handle,
worker_state,
working_directory,
} => {
self.commit_restored_worker_execution(
worker_ref,
handle,
worker_state,
WorkerStatus::Idle,
working_directory,
)?;
@@ -1656,6 +1745,7 @@ impl Runtime {
&self,
worker_ref: &WorkerRef,
handle: WorkerExecutionHandle,
initial_worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<CatalogWorkingDirectoryStatus>,
result: WorkerExecutionResult,
) -> Result<WorkerDetail, RuntimeError> {
@@ -1665,7 +1755,7 @@ impl Runtime {
worker.execution_handle = Some(handle);
worker.execution_bound = true;
worker.status = WorkerStatus::Idle;
worker.worker_state = None;
let _ = worker.apply_worker_state(&initial_worker_state);
if let Some(snapshot) = result.worker_state.as_ref() {
let _ = worker.apply_worker_state(snapshot);
}
@@ -2192,10 +2282,12 @@ impl Runtime {
match backend.restore_worker(request) {
WorkerExecutionSpawnResult::Connected {
handle,
worker_state,
working_directory,
} => self.commit_restored_worker_execution(
&candidate.worker_ref,
handle,
worker_state,
WorkerStatus::Idle,
working_directory,
)?,
@@ -2213,6 +2305,7 @@ impl Runtime {
&self,
worker_ref: &WorkerRef,
handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
status: WorkerStatus,
working_directory: Option<CatalogWorkingDirectoryStatus>,
) -> Result<(), RuntimeError> {
@@ -2223,6 +2316,7 @@ impl Runtime {
worker.execution_handle = Some(handle);
worker.execution_bound = true;
worker.status = status;
let _ = worker.apply_worker_state(&worker_state);
worker.restore_intent = restore_intent_for_status(worker.status);
worker.working_directory = working_directory;
}
@@ -2418,6 +2512,7 @@ struct RuntimeState {
status: RuntimeStatus,
execution_backend: Option<WorkerExecutionBackendRef>,
backend_resource_client: Option<BackendResourceClientRef>,
workspace_backend_resource_clients: BTreeMap<String, BackendResourceClientRef>,
#[cfg(feature = "fs-store")]
next_diagnostic_id: u64,
workers: BTreeMap<WorkerId, WorkerRecord>,
@@ -2448,6 +2543,7 @@ impl RuntimeState {
status: RuntimeStatus::Running,
execution_backend: None,
backend_resource_client: None,
workspace_backend_resource_clients: BTreeMap::new(),
#[cfg(feature = "fs-store")]
next_diagnostic_id: 1,
workers: BTreeMap::new(),
@@ -2479,6 +2575,7 @@ impl RuntimeState {
status: RuntimeStatus::Running,
execution_backend: None,
backend_resource_client: None,
workspace_backend_resource_clients: BTreeMap::new(),
#[cfg(feature = "fs-store")]
next_diagnostic_id: 1,
workers: BTreeMap::new(),
@@ -2542,6 +2639,7 @@ impl RuntimeState {
status: persisted.status,
execution_backend: None,
backend_resource_client: None,
workspace_backend_resource_clients: BTreeMap::new(),
next_diagnostic_id,
workers,
config_bundles: BTreeMap::new(),
@@ -3334,6 +3432,10 @@ fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
"repository_access_credential_unavailable",
"Repository access credential lease is unavailable or already consumed",
),
BackendResourceError::Timeout => (
"repository_access_resource_fetch_timeout",
"Timed out while fetching Repository SSH access from Workspace Backend",
),
BackendResourceError::Transport { .. } => (
"repository_access_provider_unavailable",
"Repository access credential provider is unavailable",
@@ -3605,8 +3707,9 @@ mod tests {
use super::*;
use crate::catalog::{
ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef,
RepositorySshCredentialCandidate, RepositorySshMaterializationAccess, SensitiveString,
WorkingDirectoryClaim, WorkingDirectoryRepository, WorkingDirectoryRequest,
WorkspaceApiRef,
};
use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
@@ -3660,6 +3763,10 @@ mod tests {
BackendResourceError::MissingResource,
"repository_access_credential_unavailable",
),
(
BackendResourceError::Timeout,
"repository_access_resource_fetch_timeout",
),
(
BackendResourceError::Unauthorized {
message: "denied".to_string(),
@@ -3913,7 +4020,7 @@ mod tests {
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
materializer: MaterializerKind::RuntimeGitCache,
materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
@@ -3921,10 +4028,12 @@ mod tests {
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_candidates: vec![RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
private_key: SensitiveString::new("private-key-bytes"),
}],
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
@@ -3933,7 +4042,6 @@ mod tests {
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: repository_resource_handle(),
private_key: SensitiveString::new("private-key-bytes"),
known_hosts_entry: SensitiveString::new("known-hosts-entry"),
}),
}),
@@ -3993,20 +4101,35 @@ mod tests {
runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle();
runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
.install_workspace_backend_resource_client(
"workspace-1",
Arc::new(TestRepositoryResourceClient {
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE
.to_string(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
private_key: "private-key-bytes".to_string(),
credential_candidates: vec![
crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
private_key: "private-key-bytes-1".to_string(),
},
crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
private_key: "private-key-bytes-2".to_string(),
},
],
known_hosts_entry: "known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}))
}),
)
.unwrap();
let request = WorkingDirectoryRepositoryAccessRequest {
working_directory_id: "working-directory-1".to_string(),
@@ -4016,10 +4139,19 @@ mod tests {
operation_id: "operation-1".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_candidates: vec![
RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
private_key: SensitiveString::default(),
},
RepositorySshCredentialCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
private_key: SensitiveString::default(),
},
],
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
@@ -4028,7 +4160,6 @@ mod tests {
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(),
}),
},
@@ -4049,7 +4180,23 @@ mod tests {
let accesses = backend.repository_accesses.lock().unwrap();
assert_eq!(accesses.len(), 1);
let access = accesses[0].materialization.ssh.as_ref().unwrap();
assert_eq!(access.private_key.expose(), "private-key-bytes");
assert_eq!(access.credential_candidates.len(), 2);
assert_eq!(
access.credential_candidates[0].credential_id,
"credential-1"
);
assert_eq!(
access.credential_candidates[1].credential_id,
"credential-2"
);
assert_eq!(
access.credential_candidates[0].private_key.expose(),
"private-key-bytes-1"
);
assert_eq!(
access.credential_candidates[1].private_key.expose(),
"private-key-bytes-2"
);
assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry");
}
@@ -4062,20 +4209,35 @@ mod tests {
runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle();
runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient {
.install_workspace_backend_resource_client(
"workspace-1",
Arc::new(TestRepositoryResourceClient {
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
kind: crate::resource::BackendResourceKind::RepositorySshAccess,
resource_id: handle.resource_id.clone(),
digest: handle.digest.clone(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(),
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE
.to_string(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret {
private_key: "create-private-key-bytes".to_string(),
credential_candidates: vec![
crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
private_key: "create-private-key-bytes-1".to_string(),
},
crate::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
private_key: "create-private-key-bytes-2".to_string(),
},
],
known_hosts_entry: "create-known-hosts-entry".to_string(),
})
.unwrap(),
audit_correlation_id: handle.audit_correlation_id.clone(),
})),
}))
}),
)
.unwrap();
let request = WorkingDirectoryRequest {
repository: WorkingDirectoryRepository {
@@ -4089,7 +4251,7 @@ mod tests {
source_fingerprint: "sha256:source".to_string(),
selector: None,
},
materializer: MaterializerKind::RuntimeGitCache,
materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(),
@@ -4097,10 +4259,19 @@ mod tests {
operation_id: "operation-create".to_string(),
config_revision: 1,
config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess {
credential_candidates: vec![
RepositorySshCredentialCandidate {
credential_id: "credential-1".to_string(),
credential_revision: 1,
private_key: SensitiveString::default(),
},
RepositorySshCredentialCandidate {
credential_id: "credential-2".to_string(),
credential_revision: 3,
private_key: SensitiveString::default(),
},
],
host_trust_id: "host-trust-1".to_string(),
host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly,
@@ -4109,7 +4280,6 @@ mod tests {
repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(),
}),
}),
@@ -4128,7 +4298,14 @@ mod tests {
.as_ref()
.and_then(|materialization| materialization.ssh.as_ref())
.unwrap();
assert_eq!(access.private_key.expose(), "create-private-key-bytes");
assert_eq!(
access.credential_candidates[0].private_key.expose(),
"create-private-key-bytes-1"
);
assert_eq!(
access.credential_candidates[1].private_key.expose(),
"create-private-key-bytes-2"
);
assert_eq!(
access.known_hosts_entry.expose(),
"create-known-hosts-entry"
@@ -4325,6 +4502,10 @@ mod tests {
.insert(request.worker_ref.worker_id.clone(), request.context);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
@@ -4354,6 +4535,10 @@ mod tests {
.insert(request.worker_ref.worker_id.clone(), request.context);
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
@@ -5135,7 +5320,27 @@ mod tests {
let detail = runtime.create_worker(request).unwrap();
assert_eq!(detail.status, WorkerStatus::Idle);
assert_eq!(detail.worker_state, None);
assert_eq!(
detail.worker_state.as_ref().map(|snapshot| &snapshot.state),
Some(&protocol::WorkerState::Idle)
);
}
#[test]
fn restored_worker_exposes_the_backend_initial_state_snapshot() {
let (runtime, _) = runtime_and_backend();
let created = runtime
.create_worker(task_request("restore initial state"))
.unwrap();
runtime.stop_worker(&created.worker_ref, None).unwrap();
let restored = runtime.restore_worker(&created.worker_ref).unwrap();
let worker_state = restored
.worker_state
.expect("restored Worker must expose its initial state");
assert_eq!(worker_state.execution_generation, 2);
assert_eq!(worker_state.state, protocol::WorkerState::Idle);
}
#[test]
@@ -5414,6 +5619,10 @@ mod tests {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
worker_state: protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
working_directory: request
.working_directory
.as_ref()
@@ -5513,7 +5722,13 @@ mod tests {
assert_eq!(*backend.run_generations.lock().unwrap(), vec![1, 2]);
let restored = runtime.worker_detail(&detail.worker_ref).unwrap();
assert_eq!(restored.status, WorkerStatus::Idle);
assert_eq!(restored.worker_state, None);
assert_eq!(
restored
.worker_state
.as_ref()
.map(|snapshot| (snapshot.execution_generation, &snapshot.state)),
Some((2, &protocol::WorkerState::Idle))
);
}
#[test]
@@ -0,0 +1,368 @@
//! Side-effect-free SSH host key discovery for Repository trust enrollment.
//!
//! Probing only observes public host keys. It does not persist trust, use clone
//! credentials, or authenticate to the target host.
use base64::Engine as _;
use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use std::net::IpAddr;
use std::path::Path;
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
pub const SSH_HOST_KEY_PROBE_PATH: &str = "/v1/repositories/ssh/probe";
pub const SSH_HOST_KEY_PROBE_OPERATION: &str = "workdirs:operate";
pub(crate) const SSH_KEYSCAN_TIMEOUT: Duration = Duration::from_secs(10);
const SSH_KEYSCAN_CONNECT_TIMEOUT_SECONDS: &str = "5";
const MAX_SSH_KEYSCAN_OUTPUT_BYTES: usize = 64 * 1024;
const MAX_PROBE_CANDIDATES: usize = 32;
const MAX_DIAGNOSTIC_BYTES: usize = 256;
/// `POST /v1/repositories/ssh/probe` request.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SshHostKeyProbeRequest {
pub hostname: String,
pub port: u16,
}
/// One public host key observed by an SSH host key probe.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SshHostKeyCandidate {
/// Canonical OpenSSH public key text (`algorithm base64-key`), without a host prefix.
pub public_key: String,
/// OpenSSH public key algorithm name.
pub algorithm: String,
/// OpenSSH SHA-256 fingerprint (`SHA256:base64-digest`).
pub fingerprint: String,
}
/// `POST /v1/repositories/ssh/probe` response.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct SshHostKeyProbeResponse {
pub candidates: Vec<SshHostKeyCandidate>,
}
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum SshHostKeyProbeError {
#[error("SSH host key probe hostname is invalid")]
InvalidHostname,
#[error("SSH host key probe port must be greater than zero")]
InvalidPort,
#[error("SSH host key probe executable is unavailable")]
Unavailable,
#[error("SSH host key probe timed out")]
Timeout,
#[error("SSH host key probe failed: {diagnostic}")]
Failed { diagnostic: String },
}
/// Observe the target's public Ed25519 host keys without persisting trust or using credentials.
pub async fn probe_ssh_host_keys(
request: &SshHostKeyProbeRequest,
) -> Result<SshHostKeyProbeResponse, SshHostKeyProbeError> {
probe_ssh_host_keys_with_program(request, Path::new("ssh-keyscan"), SSH_KEYSCAN_TIMEOUT).await
}
pub(crate) async fn probe_ssh_host_keys_with_program(
request: &SshHostKeyProbeRequest,
program: &Path,
timeout: Duration,
) -> Result<SshHostKeyProbeResponse, SshHostKeyProbeError> {
validate_request(request)?;
let mut command = Command::new(program);
command
.args(["-T", SSH_KEYSCAN_CONNECT_TIMEOUT_SECONDS])
.arg("-p")
.arg(request.port.to_string())
.args(["-t", "ed25519"])
.arg(&request.hostname)
.stdin(Stdio::null())
.stdout(Stdio::piped())
// ssh-keyscan diagnostics are intentionally not returned or retained: they may contain
// environment-specific details and are not needed for the public error contract.
.stderr(Stdio::null())
.kill_on_drop(true);
let output = tokio::time::timeout(timeout, command.output())
.await
.map_err(|_| SshHostKeyProbeError::Timeout)?
.map_err(|_| SshHostKeyProbeError::Unavailable)?;
if !output.status.success() {
return Err(SshHostKeyProbeError::Failed {
diagnostic: bounded_diagnostic(format!(
"ssh-keyscan exited unsuccessfully ({})",
output.status
)),
});
}
if output.stdout.len() > MAX_SSH_KEYSCAN_OUTPUT_BYTES {
return Err(SshHostKeyProbeError::Failed {
diagnostic: "ssh-keyscan output exceeded the probe limit".to_string(),
});
}
let candidates = parse_ssh_keyscan_output(&output.stdout);
if candidates.is_empty() {
return Err(SshHostKeyProbeError::Failed {
diagnostic: "ssh-keyscan returned no valid ssh-ed25519 host keys".to_string(),
});
}
Ok(SshHostKeyProbeResponse { candidates })
}
fn validate_request(request: &SshHostKeyProbeRequest) -> Result<(), SshHostKeyProbeError> {
if request.port == 0 {
return Err(SshHostKeyProbeError::InvalidPort);
}
validate_hostname(&request.hostname)
}
fn validate_hostname(hostname: &str) -> Result<(), SshHostKeyProbeError> {
if hostname.is_empty()
|| hostname.len() > 253
|| !hostname.is_ascii()
|| hostname.bytes().any(|byte| byte.is_ascii_whitespace())
|| hostname.starts_with('-')
{
return Err(SshHostKeyProbeError::InvalidHostname);
}
if hostname.parse::<IpAddr>().is_ok() {
return Ok(());
}
let hostname = hostname.strip_suffix('.').unwrap_or(hostname);
if hostname.is_empty()
|| hostname.split('.').any(|label| {
label.is_empty()
|| label.len() > 63
|| label.starts_with('-')
|| label.ends_with('-')
|| !label
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
})
{
return Err(SshHostKeyProbeError::InvalidHostname);
}
Ok(())
}
fn parse_ssh_keyscan_output(output: &[u8]) -> Vec<SshHostKeyCandidate> {
let mut seen = BTreeSet::new();
let mut candidates = Vec::new();
for line in output.split(|byte| *byte == b'\n') {
let Ok(line) = std::str::from_utf8(line) else {
continue;
};
let mut fields = line.split_ascii_whitespace();
let (Some(_host), Some(algorithm), Some(encoded_key)) =
(fields.next(), fields.next(), fields.next())
else {
continue;
};
if line.trim_start().starts_with('#') || algorithm != "ssh-ed25519" {
continue;
}
let Ok(key_blob) = STANDARD.decode(encoded_key) else {
continue;
};
if !is_ed25519_public_key_blob(&key_blob) {
continue;
}
let canonical_key = STANDARD.encode(&key_blob);
if !seen.insert(canonical_key.clone()) {
continue;
}
let public_key = format!("{algorithm} {canonical_key}");
candidates.push(SshHostKeyCandidate {
algorithm: algorithm.to_string(),
fingerprint: format!(
"SHA256:{}",
STANDARD_NO_PAD.encode(Sha256::digest(&key_blob))
),
public_key,
});
if candidates.len() == MAX_PROBE_CANDIDATES {
break;
}
}
candidates
}
fn is_ed25519_public_key_blob(blob: &[u8]) -> bool {
let Some((algorithm, rest)) = take_ssh_string(blob) else {
return false;
};
let Some((public_key, rest)) = take_ssh_string(rest) else {
return false;
};
algorithm == b"ssh-ed25519" && public_key.len() == 32 && rest.is_empty()
}
fn take_ssh_string(input: &[u8]) -> Option<(&[u8], &[u8])> {
let length = u32::from_be_bytes(input.get(..4)?.try_into().ok()?) as usize;
let value = input.get(4..4usize.checked_add(length)?)?;
let rest = input.get(4usize.checked_add(length)?..)?;
Some((value, rest))
}
fn bounded_diagnostic(mut diagnostic: String) -> String {
if diagnostic.len() <= MAX_DIAGNOSTIC_BYTES {
return diagnostic;
}
let mut end = MAX_DIAGNOSTIC_BYTES;
while !diagnostic.is_char_boundary(end) {
end -= 1;
}
diagnostic.truncate(end);
diagnostic
}
#[cfg(test)]
mod tests {
use super::*;
fn encoded_ed25519_key(seed: u8) -> String {
let mut blob = Vec::new();
blob.extend_from_slice(&("ssh-ed25519".len() as u32).to_be_bytes());
blob.extend_from_slice(b"ssh-ed25519");
blob.extend_from_slice(&32_u32.to_be_bytes());
blob.extend_from_slice(&[seed; 32]);
STANDARD.encode(blob)
}
#[test]
fn hostname_validation_rejects_option_injection_and_ambiguous_text() {
for hostname in [
"",
"-example.test",
"--help",
"example.test other.test",
"example.test\nother.test",
"example_test",
".example.test",
"example..test",
"example.test:22",
"[::1]",
"éxample.test",
] {
assert_eq!(
validate_hostname(hostname),
Err(SshHostKeyProbeError::InvalidHostname),
"{hostname:?} must be rejected"
);
}
for hostname in [
"localhost",
"example.test",
"example.test.",
"127.0.0.1",
"::1",
] {
validate_hostname(hostname).unwrap();
}
}
#[test]
fn request_validation_rejects_zero_port() {
assert_eq!(
validate_request(&SshHostKeyProbeRequest {
hostname: "example.test".to_string(),
port: 0,
}),
Err(SshHostKeyProbeError::InvalidPort)
);
}
#[test]
fn parser_accepts_only_valid_ed25519_keys_and_deduplicates() {
let key = encoded_ed25519_key(7);
let other_key = encoded_ed25519_key(8);
let output = format!(
"# comment\nexample.test ssh-rsa AAAA\nexample.test ssh-ed25519 invalid!\nexample.test ssh-ed25519 {key}\n[example.test]:2222 ssh-ed25519 {key}\nexample.test ssh-ed25519 {other_key}\n"
);
let candidates = parse_ssh_keyscan_output(output.as_bytes());
assert_eq!(candidates.len(), 2);
assert_eq!(candidates[0].algorithm, "ssh-ed25519");
assert_eq!(candidates[0].public_key, format!("ssh-ed25519 {key}"));
let decoded = STANDARD.decode(key).unwrap();
assert_eq!(
candidates[0].fingerprint,
format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(decoded)))
);
}
#[test]
fn parser_rejects_base64_that_is_not_an_ed25519_wire_key() {
let output = format!("example.test ssh-ed25519 {}\n", STANDARD.encode([1_u8; 32]));
assert!(parse_ssh_keyscan_output(output.as_bytes()).is_empty());
}
#[cfg(unix)]
#[tokio::test]
async fn unsuccessful_command_does_not_return_stderr() {
use std::os::unix::fs::PermissionsExt as _;
let temp = tempfile::tempdir().unwrap();
let program = temp.path().join("ssh-keyscan");
std::fs::write(
&program,
"#!/bin/sh\nprintf 'secret from stderr' >&2\nexit 7\n",
)
.unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap();
let error = probe_ssh_host_keys_with_program(
&SshHostKeyProbeRequest {
hostname: "example.test".to_string(),
port: 22,
},
&program,
Duration::from_secs(1),
)
.await
.unwrap_err();
let diagnostic = error.to_string();
assert!(matches!(error, SshHostKeyProbeError::Failed { .. }));
assert!(!diagnostic.contains("secret"));
assert!(diagnostic.len() <= MAX_DIAGNOSTIC_BYTES + "SSH host key probe failed: ".len());
}
#[cfg(unix)]
#[tokio::test]
async fn command_execution_times_out_without_returning_process_diagnostics() {
use std::os::unix::fs::PermissionsExt as _;
let temp = tempfile::tempdir().unwrap();
let program = temp.path().join("ssh-keyscan");
std::fs::write(
&program,
"#!/bin/sh\nprintf 'secret from stderr' >&2\nsleep 2\n",
)
.unwrap();
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o700)).unwrap();
let request = SshHostKeyProbeRequest {
hostname: "example.test".to_string(),
port: 22,
};
let error = probe_ssh_host_keys_with_program(&request, &program, Duration::from_millis(20))
.await
.unwrap_err();
assert_eq!(error, SshHostKeyProbeError::Timeout);
assert!(!error.to_string().contains("secret"));
}
}
+19 -22
View File
@@ -1548,6 +1548,10 @@ where
));
}
};
let connected_worker_state = worker_state
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone();
workers.insert(
worker_ref.clone(),
RuntimeWorkerExecution {
@@ -1560,6 +1564,7 @@ where
WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
worker_state: connected_worker_state,
working_directory: working_directory.map(|binding| binding.status()),
}
}
@@ -2243,7 +2248,7 @@ mod tests {
use crate::identity::WorkerRef;
use crate::management::RuntimeOptions;
use crate::observation::WorkerObservationCursor;
use crate::working_directory::RuntimeGitCacheMaterializer;
use crate::working_directory::RuntimeGitMaterializer;
use agen::Engine;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request};
@@ -2987,13 +2992,13 @@ mod tests {
source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")),
},
materializer: MaterializerKind::RuntimeGitCache,
materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: None,
materialization: None,
}
}
fn materialized_worktree_root(
fn materialized_clone_root(
runtime_base: &std::path::Path,
working_directory_id: &str,
) -> PathBuf {
@@ -3729,9 +3734,7 @@ mod tests {
};
let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(),
));
.with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap();
@@ -3968,9 +3971,7 @@ mod tests {
};
let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(),
));
.with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap();
@@ -3985,13 +3986,13 @@ mod tests {
.summary
.working_directory_id
.clone();
let worktree_root = materialized_worktree_root(runtime_base.path(), &workdir_id);
assert!(worktree_root.join("README.md").exists());
let clone_root = materialized_clone_root(runtime_base.path(), &workdir_id);
assert!(clone_root.join("README.md").exists());
runtime.stop_worker(&detail.worker_ref, None).unwrap();
runtime.delete_worker(&detail.worker_ref).unwrap();
assert!(worktree_root.join("README.md").exists());
assert!(clone_root.join("README.md").exists());
let status = runtime.working_directory(&workdir_id).unwrap();
assert_eq!(
status.summary.status,
@@ -4007,9 +4008,7 @@ mod tests {
let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(),
));
.with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap();
@@ -4018,8 +4017,8 @@ mod tests {
.create_working_directory(working_directory_request(repo.path()))
.unwrap();
let workdir_id = status.summary.working_directory_id.clone();
let worktree_root = materialized_worktree_root(runtime_base.path(), &workdir_id);
assert!(worktree_root.join("README.md").exists());
let clone_root = materialized_clone_root(runtime_base.path(), &workdir_id);
assert!(clone_root.join("README.md").exists());
let mut request = create_request("chat");
request.working_directory = Some(WorkingDirectoryClaim {
working_directory_id: workdir_id.clone(),
@@ -4029,7 +4028,7 @@ mod tests {
let error = runtime.create_worker(request).unwrap_err();
assert!(format!("{error:?}").contains("spawn failed"));
assert!(worktree_root.join("README.md").exists());
assert!(clone_root.join("README.md").exists());
let status = runtime.working_directory(&workdir_id).unwrap();
assert_eq!(
status.summary.status,
@@ -4043,9 +4042,7 @@ mod tests {
let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new(
runtime_base.path(),
));
.with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap();
@@ -4066,6 +4063,6 @@ mod tests {
})
.unwrap_or(0);
assert_eq!(remaining_workdirs, 0);
assert!(working_directories_root.join(".repository-cache").is_dir());
assert!(!working_directories_root.join(".repository-cache").exists());
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -863,9 +863,9 @@ mod tests {
"repository_key": "main",
"creation_selector": "refs/heads/main",
"creation_ref": "0123456789abcdef",
"materializer_kind": "local_git_worktree",
"materializer_kind": "runtime_git_clone",
"cleanup_target": {
"kind": "git_worktree",
"kind": "runtime_git_clone",
"working_directory_id": id,
"repository_key": "main"
},
@@ -1242,16 +1242,10 @@ mod tests {
#[tokio::test]
async fn scoped_broker_operations_carry_no_child_context() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![
response(json!({
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
"operation": "stat",
"result": {"path": "visible.txt", "kind": "file", "size": 8}
})),
response(json!({
"operation": "stat",
"result": {"path": "visible.txt", "kind": "file", "size": 8}
})),
]));
}))]));
let broker = workdir::WorkdirToolBroker::new(WorkspaceAttachedWorkdirSession::handle(
client.clone(),
));
@@ -1275,7 +1269,7 @@ mod tests {
.unwrap();
let requests = client.requests();
assert_eq!(requests.len(), 2);
assert_eq!(requests.len(), 1);
for request in requests {
assert_eq!(
request.path,
+279 -25
View File
@@ -607,6 +607,64 @@ pub struct WorkspaceMetadataMutationResponse {
pub diagnostics: Vec<Diagnostic>,
}
/// Lifecycle state for a Workspace-scoped Ed25519 signing identity.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceSigningIdentityState {
PendingProvisioning,
Active,
}
/// Public metadata for a Workspace signing identity. Private material and its
/// storage reference are deliberately not part of this wire authority.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceSigningIdentityPublic {
pub workspace_id: String,
pub key_id: String,
pub algorithm: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub public_key_fingerprint: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
pub state: WorkspaceSigningIdentityState,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub provisioned_at: Option<String>,
}
/// Copyable public trust bundle consumed by future Runtime enrollment work.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspacePublicIdentityBundle {
pub workspace_id: String,
pub backend_url: String,
pub key_id: String,
pub algorithm: String,
pub public_key: String,
pub public_key_fingerprint: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceSigningIdentityResponse {
pub identity: WorkspaceSigningIdentityPublic,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub public_bundle: Option<WorkspacePublicIdentityBundle>,
}
pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128;
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
@@ -1094,6 +1152,58 @@ pub struct RepositoryDetailResponse {
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RepositorySshConnectionProbeRequest {
pub runtime_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RepositorySshHostKeyCandidate {
pub algorithm: String,
pub host_key: String,
pub fingerprint: String,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RepositorySshConnectionTrustState {
Untrusted,
Verified,
Changed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RepositorySshConnectionProbeResponse {
pub workspace_id: String,
pub repository_key: String,
pub runtime_id: String,
pub hostname: String,
pub port: u16,
pub trust_state: RepositorySshConnectionTrustState,
pub host_trust_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_host_trust_revision: Option<u64>,
pub candidates: Vec<RepositorySshHostKeyCandidate>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ConfirmRepositorySshHostTrustRequest {
pub operation_id: String,
pub runtime_id: String,
pub host_key: String,
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_host_trust_revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -1138,8 +1248,7 @@ pub struct Diagnostic {
#[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryMaterializerKind {
#[default]
RuntimeGitCache,
LocalGitWorktree,
RuntimeGitClone,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -1522,6 +1631,71 @@ pub struct RuntimeSummary {
pub diagnostics: Vec<Diagnostic>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceRuntimeBindingState {
Configured,
Verified,
Revoked,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeConnectionDisplayState {
Configured,
Verified,
Unavailable,
Revoked,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeVerificationOutcome {
Verified,
ChallengeIssued,
VerificationFailed,
ConnectivityFailed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeVerificationEvidenceSummary {
pub verified_at: Option<String>,
pub last_checked_at: String,
pub last_outcome: RuntimeVerificationOutcome,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub binding_revision: u64,
pub workspace_key_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub workspace_identity_revision: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub workspace_trust_generation: u64,
pub runtime_public_key_fingerprint: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub runtime_identity_revision: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRuntimeBindingSummary {
pub state: WorkspaceRuntimeBindingState,
pub connection_state: RuntimeConnectionDisplayState,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub workspace_key_generation: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification: Option<RuntimeVerificationEvidenceSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -1531,6 +1705,8 @@ pub struct RuntimeManagementSummary {
pub removable: bool,
pub endpoint_configured: bool,
pub token_ref_configured: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binding: Option<WorkspaceRuntimeBindingSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1613,16 +1789,6 @@ pub struct RuntimeTrustKeyRevealResponse {
pub public_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PutRuntimeTrustKeyRequest {
pub public_key: String,
#[serde(default)]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -1653,12 +1819,24 @@ pub struct RuntimeTrustConflictResponse {
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimePublicIdentityBundle {
pub identity_id: String,
pub public_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct CreateRemoteRuntimeRequest {
pub runtime_id: String,
pub public_bundle: RuntimePublicIdentityBundle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
pub endpoint: String,
pub token_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1690,6 +1868,10 @@ pub enum RuntimeConnectionTestFailureKind {
pub struct RuntimeConnectionTestResponse {
pub workspace_id: String,
pub runtime_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub binding_revision: u64,
pub connection_state: RuntimeConnectionDisplayState,
pub verification: Option<RuntimeVerificationEvidenceSummary>,
pub checked_at: String,
pub status: RuntimeConnectionTestStatus,
pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
@@ -2324,6 +2506,27 @@ pub struct CreateRepositorySshCredentialRequest {
pub passphrase: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct GenerateRepositorySshCredentialRequest {
pub operation_id: String,
pub credential_id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RepositorySshPublicKey {
pub credential_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub current_revision: u64,
pub public_key_algorithm: String,
pub public_key_fingerprint: String,
pub public_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -2848,6 +3051,10 @@ pub fn catalog_typescript() -> String {
WorkspaceMetadataSettingsResponse::decl(&config),
UpdateWorkspaceMetadataRequest::decl(&config),
WorkspaceMetadataMutationResponse::decl(&config),
WorkspaceSigningIdentityState::decl(&config),
WorkspaceSigningIdentityPublic::decl(&config),
WorkspacePublicIdentityBundle::decl(&config),
WorkspaceSigningIdentityResponse::decl(&config),
ProfileSettingsResponse::decl(&config),
WorkspaceProfileSummary::decl(&config),
WorkspaceProfileSourceSummary::decl(&config),
@@ -2862,12 +3069,22 @@ pub fn catalog_typescript() -> String {
GitCommitSummary::decl(&config),
RepositoryListResponse::decl(&config),
RepositoryDetailResponse::decl(&config),
RepositorySshConnectionProbeRequest::decl(&config),
RepositorySshHostKeyCandidate::decl(&config),
RepositorySshConnectionTrustState::decl(&config),
RepositorySshConnectionProbeResponse::decl(&config),
ConfirmRepositorySshHostTrustRequest::decl(&config),
RepositoryLogResponse::decl(&config),
RuntimeSourceKind::decl(&config),
RuntimeSourceStatus::decl(&config),
RuntimeIdentityAuthority::decl(&config),
RuntimeSourceSummary::decl(&config),
RuntimeSummary::decl(&config),
WorkspaceRuntimeBindingState::decl(&config),
RuntimeConnectionDisplayState::decl(&config),
RuntimeVerificationOutcome::decl(&config),
RuntimeVerificationEvidenceSummary::decl(&config),
WorkspaceRuntimeBindingSummary::decl(&config),
RuntimeManagementSummary::decl(&config),
WorkspaceRuntimeResource::decl(&config),
RuntimeTrustKeyStatus::decl(&config),
@@ -2876,10 +3093,11 @@ pub fn catalog_typescript() -> String {
RuntimeTrustAuditEntry::decl(&config),
WorkspaceRuntimeDetail::decl(&config),
RuntimeTrustKeyRevealResponse::decl(&config),
PutRuntimeTrustKeyRequest::decl(&config),
RevokeRuntimeTrustKeyRequest::decl(&config),
RuntimeTrustConflictKind::decl(&config),
RuntimeTrustConflictResponse::decl(&config),
RuntimePublicIdentityBundle::decl(&config),
CreateRemoteRuntimeRequest::decl(&config),
RuntimeConnectionTestStatus::decl(&config),
RuntimeConnectionTestFailureKind::decl(&config),
RuntimeConnectionTestResponse::decl(&config),
@@ -2900,6 +3118,8 @@ pub fn repository_access_api_typescript() -> String {
let declarations = [
RepositorySshCredential::decl(&config),
CreateRepositorySshCredentialRequest::decl(&config),
GenerateRepositorySshCredentialRequest::decl(&config),
RepositorySshPublicKey::decl(&config),
RotateRepositorySshCredentialRequest::decl(&config),
DeleteRepositorySshCredentialRequest::decl(&config),
RepositorySshHostTrust::decl(&config),
@@ -3707,14 +3927,6 @@ mod tests {
}))
.is_err()
);
assert!(
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
"public_key": "key",
"expected_revision": 1,
"replace": true
}))
.is_err()
);
assert!(
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
"expected_revision": 1,
@@ -3729,6 +3941,9 @@ mod tests {
let compatible = serde_json::json!({
"workspace_id": "workspace-test",
"runtime_id": "runtime-test",
"binding_revision": 3,
"connection_state": "verified",
"verification": null,
"checked_at": "2026-09-01T12:00:00Z",
"status": "compatible",
"failure_kind": null,
@@ -3877,6 +4092,45 @@ mod tests {
);
}
#[test]
fn workspace_signing_identity_wire_contract_omits_private_and_pending_fields() {
let response = WorkspaceSigningIdentityResponse {
identity: WorkspaceSigningIdentityPublic {
workspace_id: "workspace-test".to_string(),
key_id: "WK-test".to_string(),
algorithm: "ed25519".to_string(),
public_key: None,
public_key_fingerprint: None,
revision: 1,
state: WorkspaceSigningIdentityState::PendingProvisioning,
created_at: "2026-01-01T00:00:00Z".to_string(),
provisioned_at: None,
},
public_bundle: None,
};
let encoded = serde_json::to_value(&response).unwrap();
assert_eq!(
encoded,
serde_json::json!({
"identity": {
"workspace_id": "workspace-test",
"key_id": "WK-test",
"algorithm": "ed25519",
"revision": 1,
"state": "pending_provisioning",
"created_at": "2026-01-01T00:00:00Z"
}
})
);
assert!(
serde_json::from_value::<WorkspaceSigningIdentityResponse>(serde_json::json!({
"identity": encoded["identity"].clone(),
"private_material_ref": "must-not-cross-the-wire"
}))
.is_err()
);
}
fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
WorkspaceWorkerDiscoveryItem {
subject: WorkspaceWorkerSubject::RuntimeWorker {
@@ -4302,7 +4556,7 @@ mod tests {
current_ref: None,
current_tree: None,
observed_at_epoch_seconds: None,
materializer_kind: WorkingDirectoryMaterializerKind::RuntimeGitCache,
materializer_kind: WorkingDirectoryMaterializerKind::RuntimeGitClone,
cleanup_target: None,
status: WorkingDirectoryStatusKind::Active,
cleanliness: None,
@@ -4339,7 +4593,7 @@ mod tests {
"items": [{
"working_directory_id": "workdir-1",
"repository_key": "main",
"materializer_kind": "runtime_git_cache",
"materializer_kind": "runtime_git_clone",
"status": "active",
"occupied_by": {
"runtime_worker_id": "worker-1",
+1 -1
View File
@@ -38,7 +38,7 @@ memory.workspace = true
merge-request.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
tower.workspace = true
tokio-tungstenite.workspace = true
tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
worker.workspace = true
workspace-api.workspace = true
workdir = { workspace = true, features = ["http-client"] }
File diff suppressed because it is too large Load Diff
+83 -2
View File
@@ -441,13 +441,49 @@ CREATE TABLE workspace_runtime_bindings (
public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL,
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
workspace_key_id TEXT,
workspace_key_generation INTEGER CHECK (workspace_key_generation > 0),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revoked_at TEXT,
PRIMARY KEY (workspace_id, runtime_id),
UNIQUE (workspace_id, public_key_fingerprint),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
CHECK (
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL)
OR
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL)
),
CHECK (
(state = 'revoked' AND revoked_at IS NOT NULL)
OR
(state != 'revoked' AND revoked_at IS NULL)
)
);
CREATE TABLE workspace_runtime_verifications (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
binding_revision INTEGER NOT NULL CHECK(binding_revision > 0),
workspace_key_id TEXT NOT NULL,
workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0),
workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0),
runtime_public_key_fingerprint TEXT NOT NULL,
runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0),
challenge_id TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
last_outcome TEXT NOT NULL,
verified_at TEXT,
checked_at TEXT NOT NULL,
PRIMARY KEY(workspace_id, runtime_id),
FOREIGN KEY(workspace_id, runtime_id)
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE,
CHECK((state = 'verified' AND verified_at IS NOT NULL)
OR (state != 'verified' AND verified_at IS NULL))
);
CREATE INDEX workspace_runtime_verifications_state_idx
ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);
CREATE TABLE workspace_runtime_binding_audit (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
@@ -588,7 +624,7 @@ CREATE TABLE workdir_create_operations (
state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')),
failure TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_revision INTEGER, source_fingerprint TEXT, credential_id TEXT, credential_revision INTEGER, host_trust_id TEXT, host_trust_revision INTEGER, repository_access_mode TEXT, cache_generation INTEGER NOT NULL DEFAULT 0,
updated_at TEXT NOT NULL, source_kind TEXT, source_uri TEXT, source_revision INTEGER, source_fingerprint TEXT, credential_id TEXT, credential_revision INTEGER, host_trust_id TEXT, host_trust_revision INTEGER, repository_access_mode TEXT,
PRIMARY KEY (workspace_id, operation_id),
UNIQUE (workspace_id, working_directory_id)
);
@@ -795,6 +831,51 @@ CREATE TABLE workspace_create_operations (
created_at TEXT NOT NULL,
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE workspace_signing_identities (
workspace_id TEXT PRIMARY KEY,
key_id TEXT NOT NULL UNIQUE,
algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'),
public_key TEXT,
public_key_fingerprint TEXT,
private_material_ref TEXT NOT NULL UNIQUE,
revision INTEGER NOT NULL CHECK (revision >= 1),
state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')),
created_at TEXT NOT NULL,
provisioned_at TEXT,
updated_at TEXT NOT NULL,
CHECK (
(state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL)
OR
(state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL)
),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE workspace_signing_identity_provisioning_operations (
operation_key TEXT PRIMARY KEY,
request_fingerprint TEXT NOT NULL,
operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')),
workspace_id TEXT NOT NULL UNIQUE,
key_id TEXT NOT NULL UNIQUE,
private_material_ref TEXT NOT NULL UNIQUE,
revision INTEGER NOT NULL CHECK (revision >= 1),
actor_account_id TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE workspace_signing_identity_audit (
event_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
key_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('provisioned')),
revision INTEGER NOT NULL CHECK (revision >= 1),
public_key_fingerprint TEXT NOT NULL,
actor_account_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE INDEX workspace_signing_identity_audit_workspace_idx
ON workspace_signing_identity_audit(workspace_id, created_at DESC);
CREATE TABLE workspace_memory_documents (
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
body_md TEXT NOT NULL,
+3
View File
@@ -34,6 +34,7 @@ mod workdir_removal;
pub mod worker_source;
pub mod workspace_catalog;
mod workspace_deletion;
pub mod workspace_signing_identity;
mod workspace_subscription;
pub use authority::{
@@ -138,6 +139,8 @@ pub enum Error {
WorkerSourceIdentity(String),
#[error("workspace identity error: {0}")]
WorkspaceIdentity(String),
#[error("Workspace signing identity error ({code}): {message}")]
WorkspaceSigningIdentity { code: String, message: String },
#[error("store error: {0}")]
Store(String),
}
+98 -445
View File
@@ -1,15 +1,17 @@
use std::collections::VecDeque;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::process::ExitCode;
use std::sync::Arc;
use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener;
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key};
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig};
use yoi_workspace_server::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding};
use yoi_workspace_server::hosts::{
EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin,
};
use yoi_workspace_server::store::{
SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
WorkspaceRuntimeBindingState,
};
use yoi_workspace_server::{
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
@@ -18,8 +20,6 @@ use yoi_workspace_server::{
#[derive(Debug)]
enum Command {
Serve(ServeOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
Migrate(MigrateOptions),
Skills(SkillsCommand),
Help,
@@ -76,8 +76,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await,
Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::Migrate(options) => run_migrate(options),
Command::Skills(command) => run_skills(command),
Command::Help => Ok(()),
@@ -91,8 +89,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
};
match command.as_str() {
"identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_options(rest).map(Command::Migrate),
"skills" => parse_skills_command(rest),
"serve" => {
@@ -107,371 +103,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help)
}
other => Err(CliError(format!(
"unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`"
"unknown command `{other}`; expected `migrate`, `skills`, or `serve`"
))),
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct ServerIdentityFile {
identity: RuntimeIdentityMaterial,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct PublicIdentityView {
identity_id: String,
public_key: String,
}
fn server_identity_path() -> PathBuf {
ServerConfig::default_server_data_root().join("identity.toml")
}
fn read_server_identity_file(
path: &Path,
) -> Result<Option<ServerIdentityFile>, Box<dyn std::error::Error>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)?;
Ok(Some(toml::from_str(&contents)?))
}
fn write_server_identity_file(
path: &Path,
identity: &ServerIdentityFile,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(identity)?;
write_secret_file(path, contents.as_bytes())?;
Ok(())
}
fn write_secret_file(path: &Path, contents: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)?;
use std::io::Write as _;
file.write_all(contents)?;
}
#[cfg(not(unix))]
{
std::fs::write(path, contents)?;
}
Ok(())
}
fn public_identity_view(identity: &RuntimeIdentityMaterial) -> PublicIdentityView {
PublicIdentityView {
identity_id: identity.identity_id.clone(),
public_key: identity.public_key.clone(),
}
}
fn run_identity_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut args = VecDeque::from(args);
let subcommand = args
.pop_front()
.ok_or_else(|| CliError("identity requires `init` or `show`".to_string()))?;
match subcommand.as_str() {
"init" => {
let mut server_id = None;
let mut replace = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
"--replace" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
replace = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown identity init argument `{flag}`"
))));
}
}
}
let server_id = server_id
.ok_or_else(|| CliError("identity init requires --server-id".to_string()))?;
let path = server_identity_path();
if read_server_identity_file(&path)?.is_some() && !replace {
return Err(Box::new(CliError(format!(
"server identity already exists at {}; pass --replace to rotate it",
path.display()
))));
}
let identity = RuntimeIdentityMaterial::generate(server_id)?;
write_server_identity_file(
&path,
&ServerIdentityFile {
identity: identity.clone(),
},
)?;
println!("server_id={}", identity.identity_id);
println!("public_key={}", identity.public_key);
println!("identity_file={}", path.display());
Ok(())
}
"show" => {
let mut json = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--json" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
json = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown identity show argument `{flag}`"
))));
}
}
}
let path = server_identity_path();
let identity = read_server_identity_file(&path)?.ok_or_else(|| {
CliError(format!(
"server identity is not initialized at {}",
path.display()
))
})?;
let view = public_identity_view(&identity.identity);
if json {
println!("{}", serde_json::to_string_pretty(&view)?);
} else {
println!("server_id={}", view.identity_id);
println!("public_key={}", view.public_key);
println!("identity_file={}", path.display());
}
Ok(())
}
_ => Err(Box::new(CliError(format!(
"unknown identity subcommand `{subcommand}`"
)))),
}
}
fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut args = VecDeque::from(args);
let subcommand = args
.pop_front()
.ok_or_else(|| CliError("trust-runtime requires `add`, `list`, or `revoke`".to_string()))?;
let database_path = ServerConfig::default_server_database_path();
if let Some(parent) = database_path.parent() {
std::fs::create_dir_all(parent)?;
}
let store = SqliteWorkspaceStore::open(&database_path)?;
match subcommand.as_str() {
"add" => {
let mut runtime_id = None;
let mut workspace_id = None;
let mut base_url = None;
let mut public_key = None;
let mut display_name = None;
let mut replace = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--runtime-id" => {
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--base-url" | "--endpoint" => {
base_url = Some(take_value(&flag, inline_value, &mut args)?)
}
"--public-key" => {
public_key = Some(take_value(&flag, inline_value, &mut args)?)
}
"--display-name" => {
display_name = Some(take_value(&flag, inline_value, &mut args)?)
}
"--replace" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
replace = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime add argument `{flag}`"
))));
}
}
}
let runtime_id = runtime_id
.ok_or_else(|| CliError("trust-runtime add requires --runtime-id".to_string()))?;
let workspace_id = workspace_id
.ok_or_else(|| CliError("trust-runtime add requires --workspace-id".to_string()))?;
if !store
.list_workspaces()?
.iter()
.any(|workspace| workspace.workspace_id == workspace_id)
{
return Err(Box::new(CliError(format!(
"Workspace `{workspace_id}` is not registered"
))));
}
let base_url = base_url
.ok_or_else(|| CliError("trust-runtime add requires --base-url".to_string()))?;
let public_key = public_key
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
decode_public_key(&public_key)?;
let now = Utc::now().to_rfc3339();
let outcome = store.upsert_workspace_runtime_binding(
WorkspaceRuntimeBinding {
workspace_id: workspace_id.clone(),
runtime_id: runtime_id.clone(),
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
base_url,
public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
created_at: now.clone(),
updated_at: now,
revoked_at: None,
},
replace,
)?;
println!("workspace_id={workspace_id}");
println!("runtime_id={runtime_id}");
println!(
"result={}",
match outcome {
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Created =>
"created",
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged =>
"unchanged",
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Replaced =>
"replaced",
}
);
println!("server_db={}", database_path.display());
Ok(())
}
"list" => {
let mut workspace_id = None;
let mut json = false;
let mut include_revoked = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--json" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
json = true;
}
"--include-revoked" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
include_revoked = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime list argument `{flag}`"
))));
}
}
}
let workspace_id = workspace_id.ok_or_else(|| {
CliError("trust-runtime list requires --workspace-id".to_string())
})?;
let records = store.list_workspace_runtime_bindings(&workspace_id, include_revoked)?;
if json {
println!("{}", serde_json::to_string_pretty(&records)?);
} else {
for runtime in records {
println!(
"workspace_id={} runtime_id={} base_url={} public_key_fingerprint={} revoked_at={}",
runtime.workspace_id,
runtime.runtime_id,
runtime.base_url,
runtime.public_key_fingerprint,
runtime.revoked_at.unwrap_or_default()
);
}
}
Ok(())
}
"revoke" => {
let mut workspace_id = None;
let mut runtime_id = None;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--runtime-id" => {
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime revoke argument `{flag}`"
))));
}
}
}
let workspace_id = workspace_id.ok_or_else(|| {
CliError("trust-runtime revoke requires --workspace-id".to_string())
})?;
let runtime_id = runtime_id.ok_or_else(|| {
CliError("trust-runtime revoke requires --runtime-id".to_string())
})?;
let now = Utc::now().to_rfc3339();
if !store.revoke_workspace_runtime_binding(&workspace_id, &runtime_id, &now)? {
return Err(Box::new(CliError(format!(
"trusted runtime `{runtime_id}` is not registered or is already revoked"
))));
}
println!("revoked_runtime_id={runtime_id}");
Ok(())
}
_ => Err(Box::new(CliError(format!(
"unknown trust-runtime subcommand `{subcommand}`"
)))),
}
}
fn split_flag_value(arg: String) -> Result<(String, Option<String>), CliError> {
if let Some((flag, value)) = arg.split_once('=') {
if flag.is_empty() {
return Err(CliError("empty flag name".to_string()));
}
Ok((flag.to_string(), Some(value.to_string())))
} else {
Ok((arg, None))
}
}
fn take_value(
flag: &str,
inline_value: Option<String>,
args: &mut VecDeque<String>,
) -> Result<String, CliError> {
if let Some(value) = inline_value {
return Ok(value);
}
args.pop_front()
.ok_or_else(|| CliError(format!("{flag} requires a value")))
}
fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(), CliError> {
if inline_value.is_some() {
return Err(CliError(format!("{flag} does not accept a value")));
}
Ok(())
}
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
match command {
SkillsCommand::List(options) => {
@@ -519,6 +155,31 @@ fn load_skill_workspace_config(
})
}
fn remote_runtime_config_from_binding(
binding: WorkspaceRuntimeBinding,
) -> Result<Option<RemoteRuntimeConfig>, CliError> {
if binding.runtime_id == EMBEDDED_RUNTIME_ID {
return Ok(None);
}
if binding.authentication_mode != WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity {
return Err(CliError(format!(
"Runtime binding '{}:{}' still uses removed legacy Server-issued authentication",
binding.workspace_id, binding.runtime_id
)));
}
let strict_public_egress = !is_loopback_runtime_origin(&binding.base_url);
Ok(Some(
RemoteRuntimeConfig::new(
binding.runtime_id,
binding.display_name,
binding.base_url,
None,
)
.with_workspace_id(binding.workspace_id)
.with_strict_public_egress(strict_public_egress),
))
}
fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>> {
if options.help {
print_migrate_help();
@@ -639,6 +300,7 @@ fn append_workspace_runtime_sources(
.into_iter()
.filter(|binding| {
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
&& binding.state == WorkspaceRuntimeBindingState::Verified
})
.collect::<Vec<_>>()
})
@@ -647,30 +309,13 @@ fn append_workspace_runtime_sources(
.into_iter()
.flatten()
.collect::<Vec<_>>();
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else {
if !bindings.is_empty() {
return Err(Box::new(CliError(
"Runtime bindings are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
)));
}
return Ok(());
for binding in bindings {
let Some(remote) = remote_runtime_config_from_binding(binding)? else {
continue;
};
for runtime in bindings {
let auth = RemoteRuntimeAuthConfig {
server_id: server_identity.identity.identity_id.clone(),
server_private_key: server_identity.identity.private_key.clone(),
};
let remote = RemoteRuntimeConfig::new(
runtime.runtime_id.clone(),
runtime.display_name,
runtime.base_url,
None,
)
.with_workspace_id(runtime.workspace_id.clone())
.with_auth(auth);
remote_runtime_sources.retain(|existing| {
existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str())
|| existing.runtime_id != runtime.runtime_id
existing.workspace_id.as_deref() != remote.workspace_id.as_deref()
|| existing.runtime_id != remote.runtime_id
});
remote_runtime_sources.push(remote);
}
@@ -840,7 +485,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() {
println!(
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list --workspace-id <WORKSPACE_ID> [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id <WORKSPACE_ID> --runtime-id <RUNTIME_ID>\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
"yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
);
}
@@ -952,60 +597,68 @@ mod tests {
"unknown serve option `--frontend=/tmp/web`"
);
}
#[test]
fn server_identity_init_requires_explicit_server_id() {
let error = run_identity_command(vec!["init".to_string()]).unwrap_err();
assert_eq!(error.to_string(), "identity init requires --server-id");
}
#[test]
fn runtime_binding_requires_explicit_replace_for_changed_authority() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
let store = SqliteWorkspaceStore::open(&path).unwrap();
rusqlite::Connection::open(&path)
.unwrap()
.execute_batch(
"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
VALUES ('owner', 'user', 'owner', 'Owner', '1', '1');
INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at)
VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1');",
)
.unwrap();
let public_key = RuntimeIdentityMaterial::generate("runtime-a")
.unwrap()
.public_key;
fn runtime_startup_rejects_legacy_server_issuer_bindings() {
let binding = WorkspaceRuntimeBinding {
workspace_id: "workspace-a".to_string(),
runtime_id: "runtime-a".to_string(),
display_name: "Runtime A".to_string(),
base_url: "http://127.0.0.1:18080".to_string(),
public_key,
public_key_fingerprint: String::new(),
workspace_id: "workspace-a".to_owned(),
runtime_id: "runtime-a".to_owned(),
display_name: "Runtime A".to_owned(),
base_url: "https://runtime.example.test".to_owned(),
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_revision: 1,
created_at: "2026-07-26T00:00:00Z".to_string(),
updated_at: "2026-07-26T00:00:00Z".to_string(),
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
};
store
.upsert_workspace_runtime_binding(binding.clone(), false)
.unwrap();
assert!(matches!(
store
.upsert_workspace_runtime_binding(binding.clone(), false)
.unwrap(),
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged
));
let mut changed = binding;
changed.base_url = "http://127.0.0.1:18081".to_string();
assert!(
store
.upsert_workspace_runtime_binding(changed.clone(), false)
.is_err()
let error = remote_runtime_config_from_binding(binding)
.unwrap_err()
.to_string();
assert_eq!(
error,
"Runtime binding 'workspace-a:runtime-a' still uses removed legacy Server-issued authentication"
);
store
.upsert_workspace_runtime_binding(changed, true)
.unwrap();
}
#[test]
fn runtime_startup_uses_non_strict_transport_for_literal_loopback_origin() {
let binding = WorkspaceRuntimeBinding {
workspace_id: "workspace-a".to_owned(),
runtime_id: "arcadia".to_owned(),
display_name: "Arcadia".to_owned(),
base_url: "http://127.0.0.1:8788".to_owned(),
public_key: "unused".to_owned(),
public_key_fingerprint: "unused".to_owned(),
binding_revision: 1,
state: WorkspaceRuntimeBindingState::Verified,
authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity,
workspace_key_id: Some("WK-test".to_owned()),
workspace_key_generation: Some(1),
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None,
};
let config = remote_runtime_config_from_binding(binding)
.unwrap()
.expect("remote Runtime config");
assert_eq!(config.base_url, "http://127.0.0.1:8788");
assert!(!config.strict_public_egress);
}
#[test]
fn parse_cli_rejects_removed_server_global_runtime_trust_commands() {
for command in ["identity", "trust-runtime"] {
let error = parse_command(&[command.to_owned()]).unwrap_err();
assert_eq!(
error.to_string(),
format!("unknown command `{command}`; expected `migrate`, `skills`, or `serve`")
);
}
}
}
+458 -17
View File
@@ -7,16 +7,19 @@ use std::sync::Arc;
use chrono::{SecondsFormat, Utc};
use config_source::ConfigSchemaContribution;
use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};
use ring::hmac;
use ring::rand::{SecureRandom, SystemRandom};
use rusqlite::{OptionalExtension, TransactionBehavior, params};
use serde::Deserialize;
use sha2::{Digest, Sha256};
use ssh_key::private::Ed25519Keypair;
use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey};
use workspace_api::{
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest, PutRepositorySshHostTrustRequest, RepositoryAccessMode,
RepositoryAccessProjection, RepositorySshAccessBinding, RepositorySshCredential,
RepositorySshHostTrust, RotateRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest, GenerateRepositorySshCredentialRequest,
PutRepositorySshHostTrustRequest, RepositoryAccessMode, RepositoryAccessProjection,
RepositorySshAccessBinding, RepositorySshCredential, RepositorySshHostTrust,
RepositorySshPublicKey, RotateRepositorySshCredentialRequest,
};
use crate::config_source::{
@@ -42,6 +45,9 @@ const MAX_NAME_BYTES: usize = 200;
const MAX_IDENTIFIER_BYTES: usize = 128;
const MASTER_KEY_BYTES: usize = 32;
const NONCE_BYTES: usize = 12;
pub const WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID: &str = "workspace-default";
const WORKSPACE_DEFAULT_REPOSITORY_SSH_OPERATION_ID: &str = "workspace-default-repository-ssh-v1";
const WORKSPACE_DEFAULT_REPOSITORY_SSH_NAME: &str = "Workspace default SSH key";
#[derive(Debug, Default)]
pub struct RepositoryAccessConfigSchemaProvider;
@@ -130,6 +136,43 @@ pub fn project_repository_access_state(
)
}
pub(crate) fn repository_ssh_endpoint(
repository_key: &str,
repository_uri: &str,
) -> Result<Option<(String, u16)>> {
if !repository_uri.contains("://") {
if let Some((identity, path)) = repository_uri.split_once(':')
&& !path.is_empty()
&& let Some((_, hostname)) = identity.rsplit_once('@')
&& !hostname.is_empty()
{
return Ok(Some((hostname.to_ascii_lowercase(), 22)));
}
}
let parsed = url::Url::parse(repository_uri).map_err(|error| {
Error::InvalidInput(format!(
"Repository `{repository_key}` has invalid SSH URI: {error}"
))
})?;
if parsed.scheme() != "ssh" {
return Ok(None);
}
if parsed.username().is_empty() || parsed.password().is_some() {
return Err(Error::InvalidInput(format!(
"Repository `{repository_key}` must use ssh://user@host[:port]/path without embedded credentials"
)));
}
let hostname = parsed.host_str().ok_or_else(|| {
Error::InvalidInput(format!(
"Repository `{repository_key}` SSH URI has no hostname"
))
})?;
Ok(Some((
hostname.to_ascii_lowercase(),
parsed.port().unwrap_or(22),
)))
}
fn project_repository_access_evaluation(
store: &dyn ControlPlaneStore,
secrets: &RepositorySecretService,
@@ -145,6 +188,9 @@ fn project_repository_access_evaluation(
.map_err(|error| {
Error::InvalidInput(format!("invalid Repository access config: {error}"))
})?;
if !config.repository_access.is_empty() {
secrets.ensure_workspace_default_credential(workspace_id)?;
}
let mut bindings = Vec::with_capacity(config.repository_access.len());
for (repository_key, access) in config.repository_access {
workspace_api::validate_repository_key(&repository_key)
@@ -181,22 +227,14 @@ fn project_repository_access_evaluation(
access.ssh.host_trust
))
})?;
let uri = url::Url::parse(&repository.source.uri).map_err(|_| {
let (hostname, port) =
repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else(
|| {
Error::InvalidInput(format!(
"Repository `{repository_key}` has an invalid SSH URI"
"Repository `{repository_key}` must use an SSH source"
))
})?;
if uri.scheme() != "ssh" || uri.username().is_empty() || uri.password().is_some() {
return Err(Error::InvalidInput(format!(
"Repository `{repository_key}` must use ssh://user@host[:port]/path without credentials"
)));
}
let hostname = uri.host_str().ok_or_else(|| {
Error::InvalidInput(format!(
"Repository `{repository_key}` SSH URI has no hostname"
))
})?;
let port = uri.port().unwrap_or(22);
},
)?;
if hostname != host_trust.hostname || port != host_trust.port {
return Err(Error::InvalidInput(format!(
"Repository `{repository_key}` SSH host does not match host trust `{}`",
@@ -245,6 +283,152 @@ impl RepositorySecretService {
})
}
fn generated_ed25519_private_key(
&self,
workspace_id: &str,
operation_id: &str,
credential_id: &str,
intent: &str,
) -> Result<String> {
let master_key = self.master_key.as_ref().ok_or_else(|| {
Error::Store("Repository secret encryption authority is unavailable".to_string())
})?;
let key = hmac::Key::new(hmac::HMAC_SHA256, master_key.as_slice());
let context = format!(
"yoi/repository-ssh-key/v1\0{workspace_id}\0{operation_id}\0{credential_id}\0{intent}"
);
let seed = hmac::sign(&key, context.as_bytes());
PrivateKey::from(Ed25519Keypair::from_seed(
seed.as_ref().try_into().map_err(|_| {
Error::Store("generated SSH Ed25519 seed had an invalid length".to_string())
})?,
))
.to_openssh(LineEnding::LF)
.map(|key| key.to_string())
.map_err(|err| Error::Store(format!("failed to encode generated SSH key: {err}")))
}
pub fn generate_credential(
&self,
workspace_id: &str,
request: GenerateRepositorySshCredentialRequest,
actor_account_id: &str,
) -> Result<RepositorySshCredential> {
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
let credential_id = validate_identifier("credential_id", &request.credential_id)?;
let name = normalize_name(&request.name)?;
let private_key = self.generated_ed25519_private_key(
workspace_id,
&operation_id,
&credential_id,
&format!("create\0{name}"),
)?;
self.create_credential(
workspace_id,
CreateRepositorySshCredentialRequest {
operation_id,
credential_id,
name,
private_key,
passphrase: None,
},
actor_account_id,
)
}
pub fn ensure_workspace_default_credential(
&self,
workspace_id: &str,
) -> Result<RepositorySshCredential> {
if let Some(credential) = self.store.with_conn(|conn| {
read_credential(
conn,
workspace_id,
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
)
})? {
return Ok(credential);
}
self.generate_credential(
workspace_id,
GenerateRepositorySshCredentialRequest {
operation_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_OPERATION_ID.to_string(),
credential_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID.to_string(),
name: WORKSPACE_DEFAULT_REPOSITORY_SSH_NAME.to_string(),
},
"workspace-system",
)
}
pub fn credential_public_key(
&self,
workspace_id: &str,
credential_id: &str,
) -> Result<Option<RepositorySshPublicKey>> {
let credential_id = validate_identifier("credential_id", credential_id)?;
let Some((credential, private_secret, passphrase_secret)) =
self.store.with_conn(|conn| {
let Some(credential) = read_credential(conn, workspace_id, &credential_id)? else {
return Ok(None);
};
let private_secret = read_sealed_secret(
conn,
workspace_id,
&credential_id,
credential.current_revision,
"private_key",
)?
.ok_or_else(|| Error::Store("credential private key is missing".to_string()))?;
let passphrase_secret = read_sealed_secret(
conn,
workspace_id,
&credential_id,
credential.current_revision,
"passphrase",
)?;
Ok(Some((credential, private_secret, passphrase_secret)))
})?
else {
return Ok(None);
};
let private_key = zeroize::Zeroizing::new(self.unseal(
workspace_id,
&credential_id,
credential.current_revision,
"private_key",
private_secret,
)?);
let passphrase = passphrase_secret
.map(|secret| {
self.unseal(
workspace_id,
&credential_id,
credential.current_revision,
"passphrase",
secret,
)
.map(zeroize::Zeroizing::new)
})
.transpose()?;
let private_key = std::str::from_utf8(private_key.as_slice())
.map_err(|_| Error::Store("credential private key is not UTF-8".to_string()))?;
let passphrase = passphrase
.as_deref()
.map(|value| std::str::from_utf8(value.as_slice()))
.transpose()
.map_err(|_| Error::Store("credential passphrase is not UTF-8".to_string()))?;
let parsed = parse_private_key(private_key, passphrase).map_err(|err| {
Error::Store(format!("stored credential private key is invalid: {err}"))
})?;
Ok(Some(RepositorySshPublicKey {
credential_id,
current_revision: credential.current_revision,
public_key_algorithm: parsed.algorithm,
public_key_fingerprint: parsed.fingerprint,
public_key: parsed.public_key,
}))
}
pub fn create_credential(
&self,
workspace_id: &str,
@@ -384,6 +568,11 @@ impl RepositorySecretService {
actor_account_id: &str,
) -> Result<RepositorySshCredential> {
let credential_id = validate_identifier("credential_id", credential_id)?;
if credential_id == WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID {
return Err(Error::WorkspaceConfigConflict(
"Workspace default SSH credential is immutable".to_string(),
));
}
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?;
let next_revision = request
@@ -529,6 +718,11 @@ impl RepositorySecretService {
projection: &RepositoryAccessProjection,
) -> Result<()> {
let credential_id = validate_identifier("credential_id", credential_id)?;
if credential_id == WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID {
return Err(Error::WorkspaceConfigConflict(
"Workspace default SSH credential is immutable".to_string(),
));
}
let operation_id = validate_identifier("operation_id", &request.operation_id)?;
let references = credential_references(projection, &credential_id);
if !references.is_empty() {
@@ -839,6 +1033,73 @@ impl RepositorySecretService {
})
}
pub fn host_trusts_for_endpoint(
&self,
workspace_id: &str,
hostname: &str,
port: u16,
) -> Result<Vec<RepositorySshHostTrust>> {
self.store.with_conn(|conn| {
let mut statement = conn.prepare(
r#"SELECT workspace_id, host_trust_id, hostname, port, key_algorithm,
host_key, fingerprint, current_revision, created_at, updated_at
FROM repository_ssh_host_trusts
WHERE workspace_id = ?1 AND lower(hostname) = lower(?2) AND port = ?3
ORDER BY host_trust_id"#,
)?;
statement
.query_map(
params![workspace_id, hostname, i64::from(port)],
read_host_trust_row,
)?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
pub fn automatic_host_trust_id(hostname: &str, port: u16) -> String {
let normalized = hostname
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') {
character.to_ascii_lowercase()
} else {
'-'
}
})
.take(96)
.collect::<String>();
format!("tofu-{normalized}-{port}")
}
pub fn default_ssh_binding_for_repository(
&self,
workspace_id: &str,
repository_key: &str,
repository_uri: &str,
) -> Result<Option<RepositorySshAccessBinding>> {
let Some((hostname, port)) = repository_ssh_endpoint(repository_key, repository_uri)?
else {
return Ok(None);
};
let matches = self.host_trusts_for_endpoint(workspace_id, &hostname, port)?;
let Some(host_trust) = matches.first() else {
return Ok(None);
};
if matches.len() > 1 {
return Err(Error::InvalidInput(format!(
"Repository `{repository_key}` matches multiple SSH host trusts for {hostname}:{port}; configure an explicit Repository access binding"
)));
}
self.ensure_workspace_default_credential(workspace_id)?;
Ok(Some(RepositorySshAccessBinding {
repository_key: repository_key.to_string(),
credential_id: WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID.to_string(),
host_trust_id: host_trust.host_trust_id.clone(),
access: RepositoryAccessMode::ReadOnly,
}))
}
pub fn lease_ssh_materialization_access(
&self,
workspace_id: &str,
@@ -1056,6 +1317,7 @@ impl RepositorySecretService {
struct ParsedKey {
algorithm: String,
fingerprint: String,
public_key: String,
}
fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result<ParsedKey> {
@@ -1092,6 +1354,9 @@ fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result<Pars
Ok(ParsedKey {
algorithm: public_key.algorithm().to_string(),
fingerprint: public_key.fingerprint(HashAlg::Sha256).to_string(),
public_key: public_key.to_openssh().map_err(|err| {
Error::Store(format!("failed to encode Repository SSH public key: {err}"))
})?,
})
}
@@ -1708,6 +1973,102 @@ mod tests {
assert!(!error.contains(secret));
}
#[test]
fn workspace_default_credential_is_generated_once_and_immutable() {
let (_dir, _store, service) = test_service();
let created = service
.ensure_workspace_default_credential("workspace-a")
.unwrap();
let replayed = service
.ensure_workspace_default_credential("workspace-a")
.unwrap();
let public_key = service
.credential_public_key(
"workspace-a",
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
)
.unwrap()
.unwrap();
assert_eq!(created, replayed);
assert_eq!(created.current_revision, 1);
assert_eq!(
public_key.public_key_fingerprint,
created.public_key_fingerprint
);
assert!(
service
.rotate_credential(
"workspace-a",
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
RotateRepositorySshCredentialRequest {
operation_id: "rotate-default".to_string(),
expected_revision: 1,
private_key: test_private_key(12).0,
passphrase: None,
},
"owner-a",
)
.is_err()
);
assert!(
service
.delete_credential(
"workspace-a",
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
DeleteRepositorySshCredentialRequest {
operation_id: "delete-default".to_string(),
expected_revision: 1,
},
"owner-a",
&RepositoryAccessProjection {
workspace_id: "workspace-a".to_string(),
config_revision: 1,
projection_digest: "sha256:empty".to_string(),
bindings: Vec::new(),
},
)
.is_err()
);
}
#[test]
fn generated_credential_is_replayable_and_exposes_only_its_public_key() {
let (_dir, _store, service) = test_service();
let request = GenerateRepositorySshCredentialRequest {
operation_id: "generate-one".to_string(),
credential_id: "workspace-key".to_string(),
name: "Workspace key".to_string(),
};
let created = service
.generate_credential("workspace-a", request.clone(), "owner-a")
.unwrap();
let replayed = service
.generate_credential("workspace-a", request, "owner-a")
.unwrap();
let public_key = service
.credential_public_key("workspace-a", "workspace-key")
.unwrap()
.unwrap();
assert_eq!(replayed, created);
assert_eq!(public_key.current_revision, created.current_revision);
assert_eq!(
public_key.public_key_fingerprint,
created.public_key_fingerprint
);
assert!(public_key.public_key.starts_with("ssh-ed25519 "));
assert!(!public_key.public_key.contains("PRIVATE KEY"));
assert!(
service
.credential_public_key("workspace-b", "workspace-key")
.unwrap()
.is_none()
);
}
#[test]
fn credential_create_rotate_replay_and_cross_workspace_scope_keep_secrets_write_only() {
let (_dir, store, service) = test_service();
@@ -1964,6 +2325,86 @@ mod tests {
);
}
#[test]
fn default_binding_resolves_unique_host_trust_for_url_and_scp_ssh_sources() {
let (_dir, _store, service) = test_service();
assert!(
service
.default_ssh_binding_for_repository(
"workspace-a",
"main",
"git@example.test:org/main.git",
)
.unwrap()
.is_none()
);
let (_, host_key) = test_private_key(10);
service
.put_host_trust(
"workspace-a",
PutRepositorySshHostTrustRequest {
operation_id: "host-default".to_string(),
host_trust_id: "example".to_string(),
hostname: "example.test".to_string(),
port: 22,
host_key,
expected_revision: None,
},
"owner-a",
)
.unwrap();
for uri in [
"ssh://git@example.test/org/main.git",
"git@example.test:org/main.git",
] {
let binding = service
.default_ssh_binding_for_repository("workspace-a", "main", uri)
.unwrap()
.unwrap();
assert_eq!(
binding.credential_id,
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID
);
assert_eq!(binding.host_trust_id, "example");
assert_eq!(binding.access, RepositoryAccessMode::ReadOnly);
}
assert!(
service
.credential_public_key(
"workspace-a",
WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID,
)
.unwrap()
.is_some()
);
let (_, second_host_key) = test_private_key(11);
service
.put_host_trust(
"workspace-a",
PutRepositorySshHostTrustRequest {
operation_id: "host-default-second".to_string(),
host_trust_id: "example-second".to_string(),
hostname: "example.test".to_string(),
port: 22,
host_key: second_host_key,
expected_revision: None,
},
"owner-a",
)
.unwrap();
assert!(
service
.default_ssh_binding_for_repository(
"workspace-a",
"main",
"git@example.test:org/main.git",
)
.is_err()
);
}
#[test]
fn referenced_resources_cannot_be_deleted() {
let (_dir, _store, service) = test_service();
+10 -1
View File
@@ -397,7 +397,13 @@ mod tests {
"1",
i64::MAX,
RepositorySshAccessSecret {
credential_candidates: vec![
worker_runtime::resource::RepositorySshAccessSecretCandidate {
credential_id: "credential-test".to_string(),
credential_revision: 1,
private_key: "private-key-bytes".to_string(),
},
],
known_hosts_entry: "known-hosts-entry".to_string(),
},
)
@@ -419,7 +425,10 @@ mod tests {
assert!(!debug.contains("private-key-bytes"));
assert!(debug.contains("REDACTED"));
let secret: RepositorySshAccessSecret = serde_json::from_slice(&response.bytes).unwrap();
assert_eq!(secret.private_key, "private-key-bytes");
assert_eq!(
secret.credential_candidates[0].private_key,
"private-key-bytes"
);
assert!(matches!(
broker.fetch_resource(request(handle, "runtime-test", None)),
Err(BackendResourceError::MissingResource)
@@ -10,12 +10,11 @@ use protocol::subscription::{
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode,
};
use tokio::sync::mpsc;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims};
use tokio_tungstenite::{client_async_tls_with_config, connect_async};
use crate::hosts::RemoteRuntimeConfig;
use crate::hosts::{RemoteRuntimeConfig, resolve_strict_remote_runtime_endpoint};
const DOWNSTREAM_QUEUE_CAPACITY: usize = 256;
const RECONNECT_DELAY: Duration = Duration::from_millis(100);
@@ -918,10 +917,43 @@ async fn connect_runtime(
.map_err(|error| format!("invalid Runtime authorization header: {error}"))?,
);
}
connect_async(request)
if config.strict_public_egress {
let base_url = config.base_url.clone();
let (_, addresses) =
tokio::task::spawn_blocking(move || resolve_strict_remote_runtime_endpoint(&base_url))
.await
.map_err(|_| {
"Runtime subscription endpoint resolution task failed".to_string()
})??;
let stream = tokio::time::timeout(config.timeout, async move {
let mut last_error = None;
for address in addresses {
match tokio::net::TcpStream::connect(address).await {
Ok(stream) => return Ok(stream),
Err(error) => last_error = Some(error),
}
}
Err(last_error
.map(|error| error.to_string())
.unwrap_or_else(|| "no validated Runtime address was available".to_string()))
})
.await
.map_err(|_| "Runtime subscription TCP connection timed out".to_string())??;
tokio::time::timeout(
config.timeout,
client_async_tls_with_config(request, stream, None, None),
)
.await
.map_err(|_| "Runtime subscription TLS/WebSocket handshake timed out".to_string())?
.map(|(socket, _)| socket)
.map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}"))
} else {
tokio::time::timeout(config.timeout, connect_async(request))
.await
.map_err(|_| "Runtime subscription connection timed out".to_string())?
.map(|(socket, _)| socket)
.map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}"))
}
}
fn runtime_endpoint(base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
@@ -935,24 +967,15 @@ fn runtime_endpoint(base_url: &str) -> String {
}
fn runtime_token(
config: &RemoteRuntimeConfig,
workspace_id: &str,
_workspace_id: &str,
) -> Result<Option<String>, String> {
let Some(auth) = config.auth.as_ref() else {
return Ok(config.bearer_token.clone());
};
let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key);
let claims = capability_claims(
&auth.server_id,
&config.runtime_id,
workspace_id,
vec!["workers:list".into()],
300,
)
.map_err(|error| error.to_string())?;
signer
.sign(&claims)
if let Some(authorization) = config.workspace_authorization.as_ref() {
return authorization
.issue("GET", "/v1/protocol/ws", "workers:list", None, &[])
.map(Some)
.map_err(|error| error.to_string())
.map_err(|error| error.message);
}
Ok(config.bearer_token.clone())
}
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
@@ -19,6 +19,10 @@ impl WorkerExecutionBackend for TestExecutionBackend {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::connected(
WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
None,
)
}
@@ -174,12 +178,18 @@ async fn equal_downstream_selectors_share_one_upstream_subscription() {
.await;
assert_eq!(status.desired_selectors, 1);
let mut running = worker
.worker_state
.clone()
.expect("connected test Worker must expose its initial state");
running.revision += 1;
running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
));
runtime
.observe_worker_event(
&worker.worker_ref,
protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
},
protocol::Event::WorkerState { snapshot: running },
)
.unwrap();
for subscription in [&mut first, &mut second] {
@@ -334,12 +344,18 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
workers[0].runtime_id.as_deref(),
Some("embedded-worker-runtime")
);
let mut running = worker
.worker_state
.clone()
.expect("connected test Worker must expose its initial state");
running.revision += 1;
running.state = protocol::WorkerState::Busy(protocol::WorkerBusyState::Run(
protocol::WorkerRunState::Running,
));
runtime
.observe_worker_event(
&worker.worker_ref,
protocol::Event::WorkerState {
snapshot: protocol::WorkerStatus::Running.into(),
},
protocol::Event::WorkerState { snapshot: running },
)
.unwrap();
assert!(matches!(next_event(&mut subscription).await,
@@ -416,3 +432,19 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
));
server.abort();
}
#[tokio::test]
async fn strict_runtime_subscription_rejects_private_endpoint_before_websocket_connect() {
let config = RemoteRuntimeConfig::new(
"runtime-private",
"Private Runtime",
"https://169.254.169.254",
None,
)
.with_strict_public_egress(true);
let error = match connect_runtime(&config, "workspace-a").await {
Err(error) => error,
Ok(_) => panic!("private endpoint unexpectedly produced a WebSocket"),
};
assert!(error.contains("endpoint host is not public"), "{error}");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -172,7 +172,6 @@ impl SqliteWorkspaceStore {
host_trust_id: &str,
host_trust_revision: u64,
repository_access_mode: &str,
cache_generation: u64,
now: &str,
) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| {
@@ -194,7 +193,6 @@ impl SqliteWorkspaceStore {
|| operation.host_trust_revision != Some(host_trust_revision)
|| operation.repository_access_mode.as_deref()
!= Some(repository_access_mode)
|| operation.cache_generation != cache_generation
{
return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` Repository access evidence changed"
@@ -206,8 +204,7 @@ impl SqliteWorkspaceStore {
r#"UPDATE workdir_create_operations
SET credential_id = ?4, credential_revision = ?5,
host_trust_id = ?6, host_trust_revision = ?7,
repository_access_mode = ?8, cache_generation = ?9,
updated_at = ?10
repository_access_mode = ?8, updated_at = ?9
WHERE workspace_id = ?1 AND operation_id = ?2
AND request_fingerprint = ?3 AND credential_id IS NULL"#,
params![
@@ -223,9 +220,6 @@ impl SqliteWorkspaceStore {
"host-trust revision is out of range".to_string()
))?,
repository_access_mode,
i64::try_from(cache_generation).map_err(|_| Error::InvalidInput(
"cache generation is out of range".to_string()
))?,
now,
],
)?;
@@ -294,7 +288,7 @@ fn read_workdir_create_operation(
config_projection_digest, source_kind, source_uri, source_revision,
source_fingerprint, credential_id, credential_revision,
host_trust_id, host_trust_revision, repository_access_mode,
cache_generation, working_directory_id, state, failure,
working_directory_id, state, failure,
created_at, updated_at
FROM workdir_create_operations
WHERE workspace_id = ?1 AND operation_id = ?2"#,
@@ -319,12 +313,11 @@ fn read_workdir_create_operation(
host_trust_id: row.get(15)?,
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
repository_access_mode: row.get(17)?,
cache_generation: row.get::<_, i64>(18)? as u64,
working_directory_id: row.get(19)?,
state: row.get(20)?,
failure: row.get(21)?,
created_at: row.get(22)?,
updated_at: row.get(23)?,
working_directory_id: row.get(18)?,
state: row.get(19)?,
failure: row.get(20)?,
created_at: row.get(21)?,
updated_at: row.get(22)?,
})
},
)
@@ -410,7 +403,6 @@ mod tests {
host_trust_id: None,
host_trust_revision: None,
repository_access_mode: None,
cache_generation: 0,
working_directory_id: "wd-1".to_string(),
state: "pending".to_string(),
failure: None,
@@ -431,14 +423,12 @@ mod tests {
"trust-1",
5,
"read_only",
2,
"2026-08-24T00:00:01Z",
)
.unwrap();
assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
assert_eq!(bound.credential_revision, Some(3));
assert_eq!(bound.host_trust_revision, Some(5));
assert_eq!(bound.cache_generation, 2);
assert!(
store
.bind_workdir_create_repository_access(
@@ -450,7 +440,6 @@ mod tests {
"trust-1",
5,
"read_only",
2,
"2026-08-24T00:00:02Z",
)
.is_err()
@@ -1096,7 +1096,6 @@ mod tests {
host_trust_id: None,
host_trust_revision: None,
repository_access_mode: None,
cache_generation: 0,
working_directory_id: "workdir-a".to_string(),
state: "pending".to_string(),
failure: None,
+13 -15
View File
@@ -10,7 +10,6 @@ use worker_runtime::auth::{
};
use worker_runtime::worker_source::InProcessWorkerMutationProof;
use crate::hosts::RemoteRuntimeConfig;
use crate::server::{ServerConfig, WorkspaceApi};
use crate::store::ControlPlaneStore;
@@ -55,7 +54,7 @@ pub async fn verify_runtime_request_source_proof_with_store(
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
let unverified = decode_runtime_request_source_claims(proof)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, workspace_id)?;
let audience = remote_audience(config, workspace_id)?;
let trusted = store
.get_workspace_runtime_binding(workspace_id, &unverified.iss)
.await
@@ -201,7 +200,7 @@ async fn verify_worker_remove_source_with(
PresentedWorkerMutationSourceProof::Remote(token) => {
let unverified = decode_worker_mutation_source_claims(token)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?;
let audience = remote_audience(config, &config.workspace_id)?;
let trusted = store
.get_workspace_runtime_binding(&config.workspace_id, &unverified.iss)
.await
@@ -357,20 +356,19 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
}
fn remote_audience<'a>(
config: &'a crate::server::ServerConfig,
runtime_id: &str,
config: &'a ServerConfig,
workspace_id: &str,
) -> Result<std::borrow::Cow<'a, str>, WorkerMutationSourceProofError> {
if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID {
return Ok(std::borrow::Cow::Owned(format!("embedded:{workspace_id}")));
}
) -> Result<&'a str, WorkerMutationSourceProofError> {
config
.remote_runtime_sources
.iter()
.find(|runtime| runtime.runtime_id == runtime_id)
.and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref())
.map(|auth| std::borrow::Cow::Borrowed(auth.server_id.as_str()))
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)
.backend_base_url
.as_deref()
.map(str::trim)
.filter(|audience| !audience.is_empty())
.ok_or_else(|| {
WorkerMutationSourceProofError::Authority(format!(
"Backend public URL is unavailable for Workspace `{workspace_id}` source proof verification"
))
})
}
fn validate_in_process_claims(
+224 -12
View File
@@ -11,6 +11,9 @@ use crate::repository_source::{parse_repository_source, repository_source_finger
use crate::store::{
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
};
use crate::workspace_signing_identity::{
WorkspaceSigningIdentityService, WorkspaceSigningMaterialStore,
};
use crate::{Error, Result};
const MAX_DISPLAY_NAME_BYTES: usize = 200;
@@ -45,11 +48,21 @@ pub struct WorkspaceCreateResult {
#[derive(Clone)]
pub struct WorkspaceCatalogService {
store: Arc<dyn ControlPlaneStore>,
signing_identities: WorkspaceSigningIdentityService,
}
impl WorkspaceCatalogService {
pub fn new(store: Arc<dyn ControlPlaneStore>) -> Self {
Self { store }
pub fn new(
store: Arc<dyn ControlPlaneStore>,
signing_materials: Arc<dyn WorkspaceSigningMaterialStore>,
) -> Self {
Self {
signing_identities: WorkspaceSigningIdentityService::new(
store.clone(),
signing_materials,
),
store,
}
}
pub fn is_empty(&self) -> Result<bool> {
@@ -118,7 +131,7 @@ impl WorkspaceCatalogService {
.map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string()))
})
.transpose()?;
let workspace_id = requested_workspace_id
let proposed_workspace_id = requested_workspace_id
.clone()
.unwrap_or_else(|| Uuid::now_v7().to_string());
let fingerprint = workspace_create_fingerprint(
@@ -130,9 +143,16 @@ impl WorkspaceCatalogService {
&default_ref,
);
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let result = self
.store
.create_workspace_bootstrap(&WorkspaceBootstrapRecord {
let (signing_identity, identity_provisioning_operation_key) =
self.signing_identities.prepare_workspace_creation(
&operation_key,
&fingerprint,
&proposed_workspace_id,
&owner_account_id,
)?;
let workspace_id = signing_identity.workspace_id.clone();
let result = self.store.create_workspace_bootstrap(
&WorkspaceBootstrapRecord {
operation_key,
request_fingerprint: fingerprint.clone(),
workspace: WorkspaceRecord {
@@ -158,7 +178,10 @@ impl WorkspaceCatalogService {
created_at: now.clone(),
updated_at: now,
},
})?;
},
&signing_identity,
&identity_provisioning_operation_key,
)?;
Ok(WorkspaceCreateResult {
workspace: result.workspace,
repository: result.repository,
@@ -214,10 +237,45 @@ fn workspace_create_fingerprint(
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*;
use crate::store::{AccountRecord, SqliteWorkspaceStore};
use crate::workspace_signing_identity::{
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
WorkspaceSigningPrivateMaterial, identity_error,
};
use workspace_api::RepositorySourceKind;
struct FailFirstMaterialWrite {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
fail: AtomicBool,
}
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
self.inner.load(material_ref)
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
if self.fail.swap(false, Ordering::SeqCst) {
return Err(identity_error(
"workspace_signing_identity_material_io_failed",
"injected private material write failure",
));
}
self.inner.put_if_absent(material_ref, material)
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.inner.delete(material_ref)
}
}
fn git_repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap();
@@ -243,7 +301,12 @@ mod tests {
#[tokio::test]
async fn create_is_atomic_and_exact_retries_converge() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let service = WorkspaceCatalogService::new(store.clone());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(),
@@ -268,6 +331,14 @@ mod tests {
replayed.workspace.workspace_id
);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
let signing_identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.expect("new Workspace signing identity");
assert_eq!(signing_identity.state, "active");
assert_eq!(signing_identity.algorithm, "ed25519");
assert!(signing_identity.public_key.is_some());
assert!(signing_identity.public_key_fingerprint.is_some());
assert_eq!(
store
.list_repositories(&created.workspace.workspace_id)
@@ -283,11 +354,137 @@ mod tests {
);
}
#[tokio::test]
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "material-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let reserved_key = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'",
[],
|row| row.get::<_, String>(0),
)
.map_err(Error::from)
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
}
#[tokio::test]
async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "db-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_create_identity_audit
BEFORE INSERT ON workspace_signing_identity_audit
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
)?;
Ok(())
})
.unwrap();
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let (reserved_key, material_ref) = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'",
[],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.map_err(Error::from)
})
.unwrap();
assert!(materials.load(&material_ref).unwrap().is_some());
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
Ok(())
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(identity.state, "active");
}
#[tokio::test]
async fn idempotency_key_reuse_with_different_payload_is_rejected() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store);
let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository();
let mut request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(),
@@ -338,7 +535,12 @@ mod tests {
updated_at: "2026-07-03T00:00:00Z".to_string(),
})
.unwrap();
let service = WorkspaceCatalogService::new(store);
let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository();
let error = service
.create(
@@ -373,7 +575,12 @@ mod tests {
updated_at: "2026-07-03T00:00:00Z".to_string(),
})
.unwrap();
let service = WorkspaceCatalogService::new(store);
let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository_a = git_repository();
let repository_b = git_repository();
let created_a = service
@@ -425,7 +632,12 @@ mod tests {
fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store.clone());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let result = service
.create(
WorkspaceCreateRequest {
@@ -80,6 +80,10 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"workspace_resource_keys",
"workspace_runtime_binding_audit",
"workspace_runtime_bindings",
"workspace_runtime_verifications",
"workspace_signing_identities",
"workspace_signing_identity_audit",
"workspace_signing_identity_provisioning_operations",
"workspace_worker_retention_policies",
"workspace_worker_retention_policy_revisions",
];
@@ -0,0 +1,880 @@
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use chrono::{SecondsFormat, Utc};
use ring::signature::KeyPair;
use serde::{Deserialize, Serialize};
use worker_runtime::auth::{RuntimeIdentityMaterial, encode_public_key};
use worker_runtime::workspace_issuer::{
WorkspaceCapabilityClaims, WorkspaceCapabilityVerificationError,
assemble_workspace_capability_token, workspace_capability_signing_input,
};
use zeroize::Zeroize;
use crate::store::{
ControlPlaneStore, WorkspaceSigningIdentityActivation,
WorkspaceSigningIdentityProvisioningOperation, WorkspaceSigningIdentityRecord,
};
use crate::{Error, Result};
pub const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519";
pub const WORKSPACE_SIGNING_IDENTITY_REVISION: u64 = 1;
const MATERIAL_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceSigningPrivateMaterial {
version: u32,
workspace_id: String,
key_id: String,
revision: u64,
private_key: String,
}
impl fmt::Debug for WorkspaceSigningPrivateMaterial {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("WorkspaceSigningPrivateMaterial")
.field("version", &self.version)
.field("workspace_id", &self.workspace_id)
.field("key_id", &self.key_id)
.field("revision", &self.revision)
.field("private_key", &"[REDACTED]")
.finish()
}
}
impl Drop for WorkspaceSigningPrivateMaterial {
fn drop(&mut self) {
self.private_key.zeroize();
}
}
impl WorkspaceSigningPrivateMaterial {
pub fn generate(workspace_id: &str, key_id: &str) -> Result<Self> {
let material = RuntimeIdentityMaterial::generate(key_id.to_string()).map_err(|error| {
identity_error(
"workspace_signing_identity_generation_failed",
format!("failed to generate Workspace signing identity: {error}"),
)
})?;
Ok(Self {
version: MATERIAL_SCHEMA_VERSION,
workspace_id: workspace_id.to_string(),
key_id: key_id.to_string(),
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
private_key: material.private_key,
})
}
fn signing_key(
&self,
expected_workspace_id: &str,
expected_key_id: &str,
expected_revision: u64,
) -> Result<ring::signature::Ed25519KeyPair> {
if self.version != MATERIAL_SCHEMA_VERSION
|| self.workspace_id != expected_workspace_id
|| self.key_id != expected_key_id
|| self.revision != expected_revision
{
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match its persisted metadata",
));
}
RuntimeIdentityMaterial {
identity_id: self.key_id.clone(),
public_key: String::new(),
private_key: self.private_key.clone(),
}
.signing_key()
.map_err(|_| {
identity_error(
"workspace_signing_identity_material_corrupt",
"Workspace signing private material is corrupt",
)
})
}
pub fn validate_and_public_key(
&self,
expected_workspace_id: &str,
expected_key_id: &str,
expected_revision: u64,
) -> Result<String> {
let signing_key =
self.signing_key(expected_workspace_id, expected_key_id, expected_revision)?;
Ok(encode_public_key(signing_key.public_key().as_ref()))
}
}
pub trait WorkspaceSigningMaterialStore: Send + Sync {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>>;
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial>;
fn delete(&self, material_ref: &str) -> Result<()>;
}
#[derive(Clone)]
pub struct WorkspaceSigningIdentityService {
store: Arc<dyn ControlPlaneStore>,
materials: Arc<dyn WorkspaceSigningMaterialStore>,
}
impl WorkspaceSigningIdentityService {
pub fn new(
store: Arc<dyn ControlPlaneStore>,
materials: Arc<dyn WorkspaceSigningMaterialStore>,
) -> Self {
Self { store, materials }
}
pub fn prepare_workspace_creation(
&self,
workspace_create_operation_key: &str,
request_fingerprint: &str,
proposed_workspace_id: &str,
actor_account_id: &str,
) -> Result<(WorkspaceSigningIdentityActivation, String)> {
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let proposed_key_id = format!("WK-{}", uuid::Uuid::now_v7().simple());
let proposed_material_ref = format!("workspace-signing/{proposed_workspace_id}/ed25519-v1");
let operation_key = format!("workspace-create:{workspace_create_operation_key}");
let operation = self.store.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: operation_key.clone(),
request_fingerprint: request_fingerprint.to_string(),
operation_kind: "workspace_create".to_string(),
workspace_id: proposed_workspace_id.to_string(),
key_id: proposed_key_id,
private_material_ref: proposed_material_ref,
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
actor_account_id: actor_account_id.to_string(),
state: "pending".to_string(),
created_at: now,
completed_at: None,
},
)?;
let activation = self.prepare_material(&operation)?;
Ok((activation, operation.operation_key))
}
pub fn provision_existing(
&self,
workspace_id: &str,
actor_account_id: &str,
) -> Result<WorkspaceSigningIdentityRecord> {
let identity = self
.store
.get_workspace_signing_identity(workspace_id)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_metadata_missing",
"Workspace signing identity metadata is missing",
)
})?;
if identity.state == "active" {
self.validate_active_material(&identity)?;
return Ok(identity);
}
if identity.state != "pending_provisioning" {
return Err(identity_error(
"workspace_signing_identity_state_invalid",
"Workspace signing identity state is invalid",
));
}
let operation_key = format!(
"existing-workspace:{workspace_id}:revision-{}",
identity.revision
);
let request_fingerprint =
provisioning_fingerprint(workspace_id, &identity.key_id, identity.revision);
let operation = self.store.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: operation_key.clone(),
request_fingerprint,
operation_kind: "existing_workspace".to_string(),
workspace_id: workspace_id.to_string(),
key_id: identity.key_id.clone(),
private_material_ref: identity.private_material_ref.clone(),
revision: identity.revision,
actor_account_id: actor_account_id.to_string(),
state: "pending".to_string(),
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
completed_at: None,
},
)?;
let activation = self.prepare_material(&operation)?;
self.store.activate_workspace_signing_identity(
&activation,
&operation.operation_key,
&operation.actor_account_id,
)
}
pub fn get_validated(&self, workspace_id: &str) -> Result<WorkspaceSigningIdentityRecord> {
let identity = self
.store
.get_workspace_signing_identity(workspace_id)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_metadata_missing",
"Workspace signing identity metadata is missing",
)
})?;
if identity.state == "active" {
self.validate_active_material(&identity)?;
}
Ok(identity)
}
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
let identity = self.get_validated(workspace_id)?;
if identity.state != "active" {
return Err(identity_error(
"workspace_signing_identity_not_provisioned",
"Workspace signing identity is not provisioned",
));
}
let material = self
.materials
.load(&identity.private_material_ref)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing",
)
})?;
let signing_key =
material.signing_key(workspace_id, &identity.key_id, identity.revision)?;
Ok(signing_key.sign(payload).as_ref().to_vec())
}
pub fn issue_workspace_capability(
&self,
workspace_id: &str,
claims: &WorkspaceCapabilityClaims,
) -> Result<String> {
let input = workspace_capability_signing_input(claims).map_err(capability_error)?;
let signature = self.sign(workspace_id, input.bytes())?;
assemble_workspace_capability_token(input, &signature).map_err(capability_error)
}
pub fn delete_material(&self, workspace_id: &str) -> Result<()> {
if let Some(identity) = self.store.get_workspace_signing_identity(workspace_id)? {
self.materials.delete(&identity.private_material_ref)?;
}
Ok(())
}
fn prepare_material(
&self,
operation: &WorkspaceSigningIdentityProvisioningOperation,
) -> Result<WorkspaceSigningIdentityActivation> {
let material = match self.materials.load(&operation.private_material_ref)? {
Some(material) => material,
None => {
let generated = WorkspaceSigningPrivateMaterial::generate(
&operation.workspace_id,
&operation.key_id,
)?;
self.materials
.put_if_absent(&operation.private_material_ref, &generated)?
}
};
let public_key = material.validate_and_public_key(
&operation.workspace_id,
&operation.key_id,
operation.revision,
)?;
let public_key_fingerprint = public_key_fingerprint(&public_key)?;
Ok(WorkspaceSigningIdentityActivation {
workspace_id: operation.workspace_id.clone(),
key_id: operation.key_id.clone(),
public_key,
public_key_fingerprint,
private_material_ref: operation.private_material_ref.clone(),
revision: operation.revision,
provisioned_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
})
}
fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> {
let material = self
.materials
.load(&identity.private_material_ref)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing",
)
})?;
let public_key = material.validate_and_public_key(
&identity.workspace_id,
&identity.key_id,
identity.revision,
)?;
let fingerprint = public_key_fingerprint(&public_key)?;
if identity.public_key.as_deref() != Some(public_key.as_str())
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str())
{
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match public metadata",
));
}
Ok(())
}
}
fn provisioning_fingerprint(workspace_id: &str, key_id: &str, revision: u64) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(workspace_id.as_bytes());
hasher.update([0]);
hasher.update(key_id.as_bytes());
hasher.update([0]);
hasher.update(revision.to_be_bytes());
format!("sha256:{}", hex_lower(&hasher.finalize()))
}
#[derive(Default)]
pub struct InMemoryWorkspaceSigningMaterialStore {
materials: std::sync::Mutex<std::collections::HashMap<String, WorkspaceSigningPrivateMaterial>>,
}
impl WorkspaceSigningMaterialStore for InMemoryWorkspaceSigningMaterialStore {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
Ok(self
.materials
.lock()
.expect("identity material store lock")
.get(material_ref)
.cloned())
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
let mut materials = self.materials.lock().expect("identity material store lock");
Ok(materials
.entry(material_ref.to_string())
.or_insert_with(|| material.clone())
.clone())
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.materials
.lock()
.expect("identity material store lock")
.remove(material_ref);
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct FsWorkspaceSigningMaterialStore {
root: PathBuf,
}
impl FsWorkspaceSigningMaterialStore {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
fn material_path(&self, material_ref: &str) -> Result<PathBuf> {
let relative = Path::new(material_ref);
if relative.as_os_str().is_empty()
|| relative.is_absolute()
|| relative.components().any(|component| {
!matches!(component, Component::Normal(_))
|| component.as_os_str().to_string_lossy().starts_with('.')
})
{
return Err(identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference is invalid",
));
}
Ok(self.root.join(relative).with_extension("json"))
}
}
impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
let path = self.material_path(material_ref)?;
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(material_io_error("read", error)),
};
serde_json::from_slice(&bytes).map(Some).map_err(|_| {
identity_error(
"workspace_signing_identity_material_corrupt",
"Workspace signing private material is corrupt",
)
})
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
let path = self.material_path(material_ref)?;
let parent = path.parent().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference has no parent",
)
})?;
ensure_private_tree(&self.root, parent)?;
let mut bytes = serde_json::to_vec(material).map_err(|_| {
identity_error(
"workspace_signing_identity_material_encode_failed",
"Workspace signing private material could not be encoded",
)
})?;
let temporary = parent.join(format!(
".workspace-signing-{}.tmp",
uuid::Uuid::now_v7().simple()
));
let write_result = (|| -> Result<()> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temporary)
.map_err(|error| material_io_error("create", error))?;
file.write_all(&bytes)
.and_then(|()| file.sync_all())
.map_err(|error| material_io_error("write", error))?;
match fs::hard_link(&temporary, &path) {
Ok(()) => sync_directory(parent),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(material_io_error("publish", error)),
}
})();
bytes.zeroize();
let cleanup_result = match fs::remove_file(&temporary) {
Ok(()) => sync_directory(parent),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(material_io_error("remove temporary", error)),
};
write_result?;
cleanup_result?;
self.load(material_ref)?.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing after publication",
)
})
}
fn delete(&self, material_ref: &str) -> Result<()> {
let path = self.material_path(material_ref)?;
match fs::remove_file(&path) {
Ok(()) => {
let parent = path.parent().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference has no parent",
)
})?;
sync_directory(parent)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(material_io_error("delete", error)),
}
}
}
fn ensure_private_tree(root: &Path, leaf: &Path) -> Result<()> {
ensure_private_directory(root)?;
if let Some(parent) = root.parent() {
sync_directory(parent)?;
}
let relative = leaf.strip_prefix(root).map_err(|_| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material path escapes its authority root",
)
})?;
let mut current = root.to_path_buf();
for component in relative.components() {
let parent = current.clone();
current.push(component);
ensure_private_directory(&current)?;
sync_directory(&parent)?;
}
Ok(())
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> Result<()> {
std::fs::File::open(path)
.and_then(|directory| directory.sync_all())
.map_err(|error| material_io_error("synchronize directory", error))
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> Result<()> {
Err(identity_error(
"workspace_signing_identity_durable_publish_unsupported",
"Workspace signing private material durable publication is unsupported on this platform",
))
}
fn ensure_private_directory(path: &Path) -> Result<()> {
fs::create_dir_all(path).map_err(|error| material_io_error("create directory", error))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
.map_err(|error| material_io_error("set directory permissions", error))?;
}
Ok(())
}
pub fn workspace_signing_material_root(database_path: &Path) -> PathBuf {
database_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("workspace-signing-identities")
}
pub fn public_key_fingerprint(public_key: &str) -> Result<String> {
let bytes = worker_runtime::auth::decode_public_key(public_key).map_err(|_| {
identity_error(
"workspace_signing_identity_public_key_invalid",
"Workspace signing public key is invalid",
)
})?;
use sha2::{Digest, Sha256};
Ok(format!("sha256:{}", hex_lower(&Sha256::digest(bytes))))
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
fn material_io_error(action: &str, error: std::io::Error) -> Error {
identity_error(
"workspace_signing_identity_material_io_failed",
format!("failed to {action} Workspace signing private material: {error}"),
)
}
fn capability_error(error: WorkspaceCapabilityVerificationError) -> Error {
identity_error(
"workspace_capability_issuance_failed",
format!("failed to issue Workspace capability: {error}"),
)
}
pub fn identity_error(code: impl Into<String>, message: impl Into<String>) -> Error {
Error::WorkspaceSigningIdentity {
code: code.into(),
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*;
struct FailFirstMaterialWrite {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
fail: AtomicBool,
}
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
self.inner.load(material_ref)
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
if self.fail.swap(false, Ordering::SeqCst) {
return Err(identity_error(
"workspace_signing_identity_material_io_failed",
"injected private material write failure",
));
}
self.inner.put_if_absent(material_ref, material)
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.inner.delete(material_ref)
}
}
#[tokio::test]
async fn existing_workspace_provisioning_is_audited_idempotent_and_fails_closed_when_missing() {
use crate::store::{AccountRecord, SqliteWorkspaceStore, WorkspaceRecord};
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
store
.upsert_account(&AccountRecord {
account_id: "account-1".to_string(),
kind: "user".to_string(),
handle: "owner".to_string(),
display_name: "Owner".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-1".to_string(),
owner_account_id: "account-1".to_string(),
display_name: "Workspace".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.await
.unwrap();
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let failing_service = WorkspaceSigningIdentityService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
assert_eq!(
failing_service.get_validated("workspace-1").unwrap().state,
"pending_provisioning"
);
let error = failing_service
.provision_existing("workspace-1", "account-1")
.unwrap_err();
assert!(matches!(
error,
Error::WorkspaceSigningIdentity { ref code, .. }
if code == "workspace_signing_identity_material_io_failed"
));
assert_eq!(
store
.get_workspace_signing_identity("workspace-1")
.unwrap()
.unwrap()
.state,
"pending_provisioning"
);
drop(failing_service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let provisioned = service
.provision_existing("workspace-1", "account-1")
.unwrap();
assert_eq!(provisioned.state, "active");
assert!(provisioned.public_key.is_some());
let payload = b"Workspace authority proof";
let signature = service.sign("workspace-1", payload).unwrap();
let public_key =
worker_runtime::auth::decode_public_key(provisioned.public_key.as_deref().unwrap())
.unwrap();
ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key)
.verify(payload, &signature)
.unwrap();
assert_eq!(
service
.provision_existing("workspace-1", "account-1")
.unwrap(),
provisioned
);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_signing_identity_audit WHERE workspace_id = 'workspace-1'",
[],
|row| row.get::<_, i64>(0),
)?,
1
);
Ok(())
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-2".to_string(),
owner_account_id: "account-1".to_string(),
display_name: "Workspace 2".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.await
.unwrap();
let pending = store
.get_workspace_signing_identity("workspace-2")
.unwrap()
.unwrap();
let operation = store
.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: "existing-workspace:workspace-2:revision-1".to_string(),
request_fingerprint: provisioning_fingerprint(
"workspace-2",
&pending.key_id,
pending.revision,
),
operation_kind: "existing_workspace".to_string(),
workspace_id: "workspace-2".to_string(),
key_id: pending.key_id.clone(),
private_material_ref: pending.private_material_ref.clone(),
revision: pending.revision,
actor_account_id: "account-1".to_string(),
state: "pending".to_string(),
created_at: "1".to_string(),
completed_at: None,
},
)
.unwrap();
let activation = service.prepare_material(&operation).unwrap();
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_signing_identity_audit
BEFORE INSERT ON workspace_signing_identity_audit
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
)?;
Ok(())
})
.unwrap();
assert!(
store
.activate_workspace_signing_identity(
&activation,
&operation.operation_key,
"account-1",
)
.is_err()
);
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_signing_identity_audit;")?;
Ok(())
})
.unwrap();
assert_eq!(
store
.get_workspace_signing_identity("workspace-2")
.unwrap()
.unwrap()
.state,
"pending_provisioning"
);
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let recovered = restarted
.provision_existing("workspace-2", "account-1")
.unwrap();
assert_eq!(recovered.key_id, activation.key_id);
assert_eq!(
recovered.public_key_fingerprint.as_deref(),
Some(activation.public_key_fingerprint.as_str())
);
materials.delete(&provisioned.private_material_ref).unwrap();
let error = restarted.get_validated("workspace-1").unwrap_err();
assert!(matches!(
error,
Error::WorkspaceSigningIdentity { ref code, .. }
if code == "workspace_signing_identity_material_missing"
));
}
#[test]
fn file_store_round_trips_private_material_without_overwrite() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
let first = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let first_public = first.validate_and_public_key("ws-1", "WK-1", 1).unwrap();
let persisted = store.put_if_absent("ws-1/ed25519-v1", &first).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(temp.path().join("identities"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
fs::metadata(store.material_path("ws-1/ed25519-v1").unwrap())
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
assert_eq!(
persisted
.validate_and_public_key("ws-1", "WK-1", 1)
.unwrap(),
first_public
);
let second = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let persisted = store.put_if_absent("ws-1/ed25519-v1", &second).unwrap();
assert_eq!(
persisted
.validate_and_public_key("ws-1", "WK-1", 1)
.unwrap(),
first_public
);
}
#[test]
fn corrupt_and_cross_workspace_material_fail_closed() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
store.put_if_absent("ws-1/ed25519-v1", &material).unwrap();
let loaded = store.load("ws-1/ed25519-v1").unwrap().unwrap();
assert!(loaded.validate_and_public_key("ws-2", "WK-1", 1).is_err());
fs::write(store.material_path("ws-1/ed25519-v1").unwrap(), b"not json").unwrap();
assert!(store.load("ws-1/ed25519-v1").is_err());
}
}
+3 -3
View File
@@ -25,9 +25,9 @@ A resolved Manifest is the concrete contract used to create or restore a Worker.
Source/partial layers may omit fields. Resolved manifests should be explicit enough that Worker creation does not depend on ambient configuration later changing under it.
`--manifest <path>` exists as an explicit low-level escape hatch. Normal fresh startup should select a Profile through `profiles.toml` / builtin defaults rather than ambient manifest cascades.
`--manifest <path>` exists as an explicit low-level escape hatch. Normal fresh startup selects a `builtin:*` or `project:*` Profile from the Backend-managed Workspace Config revision rather than applying an ambient manifest cascade.
For normal Profile/default startup, a workspace may add `.yoi/override.local.toml` as a final local manifest layer. Yoi discovers the nearest ancestor `.yoi/override.local.toml` from the workspace base used for profile resolution, resolves relative paths in that file against its containing `.yoi` directory, and applies it after the selected Profile and builtin defaults. This file is intended for machine-local choices such as provider/model, worker language, prompt pack, and permission policy tweaks; it is ignored by git via the repository `*.local.*` rule. It is not applied in explicit `--manifest <path>` mode, and it cannot set `worker.name` because Worker identity remains a runtime input.
Project Profiles are evaluated from the revisioned Virtual Config's Decodal source/import closure. The Backend packages that closure into a digest-bound Profile source archive, delivers it with the resolved launch bundle, and the Worker persists the resulting Manifest for restore. Files below the Workdir are not implicit Profile override layers.
## Local stdio MCP server declarations
@@ -77,7 +77,7 @@ Prompts live under `resources/prompts` so builtins, project overrides, and user
The prompt layer should explain policy and behavior, but it should not smuggle volatile state into model context. Runtime facts that affect later turns must still go through history.
Builtin resources should be embedded at compile time. User/project profiles, explicit profile paths, prompt overlays, provider/model overrides, and explicit manifests remain filesystem-based.
Builtin resources should be embedded at compile time. Project Profiles and prompt overlays belong to the Backend-managed revisioned Workspace Config and travel as digest-bound source archives. User Profile registries, provider/model catalog overrides, and explicit low-level Manifests remain filesystem-based where those local resolution paths are used.
## Why this separation matters
+2
View File
@@ -24,6 +24,8 @@ Responsibilities are split as follows:
The Backend can project Runtime and Worker state, but it should not become a hidden filesystem/runtime implementation. Runtime observations should be reconstructable from Runtime APIs and committed Backend records.
Remote Runtime authentication follows the same Workspace boundary. The Server signs each Runtime request with the target Workspace signing identity, and the Runtime verifies it against the installed Workspace issuer bundle. Runtime-to-Server source proof is signed by the Runtime identity and uses the bundle's Backend URL as audience. Server-global signing identities, Runtime-side global Server trust, and static bearer fallback are not Remote Workspace authority. Provisioning and rotation are described in [Workspace ↔ Runtime authentication](../development/server-runtime-auth.md).
## Docker image layout
Docker images are built through Nix `dockerTools.buildImage`, not through a root Dockerfile.
+9 -27
View File
@@ -4,35 +4,15 @@ This repository is developed with Yoi itself. Dogfooding is valuable because it
## Pre-restart gate
Never use the live dogfood Server or Runtime as the first startup test for a new
binary. A dogfood restart is allowed only after this sequence succeeds:
Never use the live dogfood Server or Runtime as the first startup test for a new binary. A dogfood restart is allowed only after this sequence succeeds:
1. Build the production entrypoints:
`cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
2. Run the focused and dependent tests for the changed contracts, followed by
`cargo fmt --all -- --check` and `git diff --check HEAD`.
3. Run `scripts/isolated-startup-smoke.sh` from an external shell/process.
4. Inspect any failed run's retained `/tmp/yoi-isolated-startup-smoke.*` logs;
do not restart dogfood until the cause is fixed and the smoke passes.
5. Have an external supervisor or operator restart Server and Runtime. A Worker
hosted by the target Runtime must never terminate its own Runtime.
6. Verify post-restart readiness through the Workspace Runtime projection and a
real restored Worker operation before treating the environment as healthy.
1. Build the production entrypoints: `cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
2. Run the focused and dependent tests for the changed contracts, followed by workspace-root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`.
3. Exercise the provisioning and operational checks in [Workspace ↔ Runtime authentication](server-runtime-auth.md) against isolated Server DB, Runtime data, and ports. The Workspace owner must create the binding, the Runtime operator must install the Workspace issuer bundle, and the challenge proof must become verified.
4. Have an external supervisor or operator restart Server and Runtime at the same generation. A Worker hosted by the target Runtime must never terminate its own Runtime.
5. Verify post-restart readiness through the Workspace Runtime projection, ping, Worker list/create, protocol subscription, and a Runtime-to-Server source-proof operation before treating the environment as healthy.
The smoke harness runs the normal `yoi-server` and `yoi-runtime` binaries using
separate `HOME`, `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, temporary Git repository,
Server database, Runtime fs store, identity/trust material, and non-dogfood
ports. It fails if either port is already occupied, if state escapes the
temporary root, if a process exits unexpectedly, if Runtime readiness is not
visible through Server, or if startup logs contain a panic, migration collision,
or Worker execution restore failure. It also proves that a listening Server
without its configured Runtime is not readiness and restarts the isolated
Runtime once to exercise persistence reopen.
Override `YOI_SMOKE_SERVER_BIN`, `YOI_SMOKE_RUNTIME_BIN`,
`YOI_SMOKE_SERVER_PORT`, or `YOI_SMOKE_RUNTIME_PORT` only when a separate build
or port is intentionally under test. Set `YOI_SMOKE_KEEP=1` to retain successful
artifacts. Failed artifacts are retained automatically.
The former isolated startup shell harness depended on removed Server-global trust commands and is intentionally not a fallback smoke path. New automated startup coverage must provision the same Workspace-scoped binding and challenge authority used by production rather than recreating global trust or seeding private authority directly.
## What to record
@@ -45,6 +25,8 @@ A report is useful when it explains:
- what design boundary was missing
- what evidence was observed
For a Remote Runtime rollout, also record the source commit, binary generation, Server schema version, Runtime binding revision, Workspace key generation, and typed HTTP/WebSocket outcomes. A successful document response does not outweigh visible UI, console, or API errors.
## Runtime command caveat
After rebuilding and restarting during dogfooding, `current_exe()` can point at a deleted binary path. Use typed runtime-command configuration and the development-only `YOI_POD_RUNTIME_COMMAND` executable override rather than reviving shell-command overrides.
+5 -5
View File
@@ -224,13 +224,13 @@ The expected authoring flow is Rust-first: generate the starter, edit `src/lib.r
## Enabling a Plugin in a workspace
Enablement belongs in the resolved Profile/config path for the workspace. For local dogfooding or private experiments, use the ignored local overlay rather than committing secrets or local paths:
Enablement belongs in the resolved Profile/config path for the workspace. Add it to the project Profile source selected by the Backend-managed, revisioned Workspace Virtual Config. Ambient files below the Workdir are not a Profile override layer and are not read when the Worker starts. Keep raw secrets and machine-local paths out of the Profile; refer to separately managed secrets where a capability supports them.
The following TOML shows the equivalent low-level Profile/config artifact shape; it is not an ambient workspace override file:
```toml
# .yoi/override.local.toml
[features]
plugins = true
[feature.plugins]
enabled = true
[[plugins.enabled]]
id = "project:example.echo"
+52 -242
View File
@@ -1,266 +1,76 @@
# Server / Runtime manual auth setup
# Workspace ↔ Runtime 認証
Workspace Server and Worker Runtime authenticate remote Runtime control traffic with manually exchanged Ed25519 public keys and short-lived Server-signed capability tokens.
Yoi の Remote Runtime 認証は Workspace ごとの署名 identity を authority とする。
Server-global な署名鍵や Runtime 側の trusted-Server catalog は使わない。
This is a non-interactive bootstrap flow. Commands fail when required flags are missing, and existing identity/trust records are not overwritten unless `--replace` is passed explicitly.
## Authority
## Authority boundary
- Server DB は Workspace ごとの signing identity と Runtime binding を保持する。
- Runtime は `trust-workspace` で受理した `WorkspaceIssuerAuthorizationBundle` を保持する。
- bundle は `workspace_id`、Workspace key id/generation、Workspace public key、Backend URL、許可された Runtime identity を固定する。
- Server → Runtime の各 HTTP / WebSocket request は、対象 Workspace の signing identity で短命な capability token を発行する。
- Runtime は request method、`path_and_query`、body digest、permission、Workspace、Runtime、key generation、expiry、JTI を検証する。
- Runtime → Server の source proof は Runtime identity で署名し、対象 Workspace と bundle の Backend URL を audience に固定する。
- Server は現在の Workspace Runtime binding、Runtime public key、Backend public URL、request target、body digest、permission、expiry、replay state を検証する。
- Workspace Server is the workspace control plane. It owns trusted Runtime records in the Server DB and signs per-request Runtime capability tokens.
- Runtime owns Worker execution. It does not own a workspace registry or workspace list.
- Runtime API paths remain worker-centric; workspace scope is carried in the signed auth context and enforced by Runtime-side authorization/filtering code.
- Browser/Web clients should talk to Workspace Server, not directly to Runtime.
旧 Server identity/trust 管理 command と旧 Runtime-side Server trust command、旧 Runtime auth key flags は廃止済みである。これらに相当する Server-global trust を fallback として使ってはならない。
## Identifiers used in examples
## Provisioning
Replace these values for the deployment:
1. Runtime identity を初期化する。
```text
SERVER_ID=server-main
RUNTIME_ID=runtime-main
RUNTIME_BASE_URL=http://127.0.0.1:38800
```
```sh
yoi-runtime identity init --runtime-id <runtime-id>
yoi-runtime identity show
```
`SERVER_ID` is the issuer id in Server-signed tokens. `RUNTIME_ID` is the token audience and must match the Runtime identity.
2. Workspace owner が Settings → Runtimes から Runtime public bundle と endpoint を登録する。
3. Server が Workspace issuer bundle と challenge を発行する。
4. operator が bundle を Runtime に追加する。
## 1. Create and show the Server identity
```sh
yoi-runtime trust-workspace add --bundle <workspace-issuer-bundle.json>
yoi-runtime trust-workspace show --workspace-id <workspace-id>
```
From the Workspace Server host:
5. Runtime が challenge proof を生成し、Workspace owner が Server に submit する。
6. Server が verified binding を commit した後、通常の Workspace-signed request が利用可能になる。
```bash
yoi-server identity init --server-id server-main
```
同じ Runtime identity は異なる Workspace から独立して信頼できる。trust record、replay protection、binding、失効はすべて Workspace scope で評価する。
Show the public identity and copy the `public_key` value:
## Runtime auth file
```bash
yoi-server identity show --json
```
`runtime-auth.toml` は Runtime identity と Workspace issuer records のみを authority とする。
旧 Server trust entry は読み飛ばされ、以後の identity / `trust-workspace` 更新時に書き戻されない。旧 entry を残しても認証には使用されない。
The Server private identity is stored in the Yoi data directory under the Server data root, currently:
`trust-workspace` の file store は次を fail closed で検証する。
```text
<data_dir>/server/identity.toml
```
- 最大 8 MiB
- 最大 4,096 records
- exact Workspace / Runtime identity
- key id/generation と public key fingerprint
- normalized Backend URL
- replace 時の expected current generation
- list は `offset` / `limit` 必須で、1 page 最大 100 records
On Unix this file is written with `0600` permissions. Do not copy the private key to Runtime or commit it to the repository.
## Local token
## 2. Create and show the Runtime identity
`--local-token` は明示的な local Runtime 呼び出し専用であり、Remote Workspace binding の代替ではない。Workspace issuer auth が有効な Remote Runtime request は Workspace capability token を使う。
From the Runtime host, using the same Runtime storage flags that the Runtime server process will use:
## Rotation と失効
```bash
yoi-runtime identity init --runtime-id runtime-main
```
Workspace signing key または Runtime key の変更は、現在 binding を置き換える明示的な provisioning 操作として行う。古い generation、古い Runtime key、revoked binding、失効済み token、replayed JTI は即時拒否する。
Show the public identity and copy the `public_key` value:
Server の Runtime cache は現在の persisted binding 全体と照合する。endpoint、Runtime public key/fingerprint、binding revision、Workspace key generation の変更を検知した場合、stale client を利用しない。
```bash
yoi-runtime identity show --json
```
## 運用確認
By default, Runtime auth state is stored at:
Remote Runtime を有効化した後は次を確認する。
```text
<data_dir>/runtime/auth.toml
```
1. `yoi-runtime trust-workspace show --workspace-id <workspace-id>` が期待する bundle を表示する。
2. Workspace Settings の Runtime binding が `verified` で、現在の key id/generation と verification evidence を表示する。
3. Runtime ping、Worker list/create、`worker.protocol` subscription が Workspace-signed token で成功する。
4. wrong Workspace、wrong Runtime、wrong target/body、expired token、revoked/replaced binding、replayed JTI が拒否される。
5. Runtime → Server source proof が configured Backend public URL audience と一致し、spoofed headers だけでは認証されない。
If the Runtime process is launched with `--fs-root` or `--fs-runtime-dir`, pass the same flags to every `identity` and `trust-server` command. Otherwise the setup command may write an auth file that the server process never reads.
Example with explicit Runtime storage:
```bash
yoi-runtime identity init \
--runtime-id runtime-main \
--fs-root /var/lib/yoi-runtime
yoi-runtime identity show \
--json \
--fs-root /var/lib/yoi-runtime
```
## 3. Register the Server public key on Runtime
On the Runtime host, register the Server public key copied from `yoi-server identity show --json`:
```bash
yoi-runtime trust-server add \
--server-id server-main \
--public-key '<SERVER_PUBLIC_KEY>'
```
With explicit Runtime storage, keep using the same storage flags:
```bash
yoi-runtime trust-server add \
--server-id server-main \
--public-key '<SERVER_PUBLIC_KEY>' \
--fs-root /var/lib/yoi-runtime
```
Verify:
```bash
yoi-runtime trust-server list --json
```
## 4. Register the Runtime public key and endpoint on Server
On the Workspace Server host, register the Runtime public key copied from `yoi-runtime identity show --json`:
```bash
yoi-server trust-runtime add \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main \
--base-url http://127.0.0.1:38800 \
--public-key '<RUNTIME_PUBLIC_KEY>' \
--display-name 'Runtime main'
```
This writes a Workspace-scoped Runtime binding and trust fingerprint to the Server DB. During `yoi-server serve`, active bindings are loaded as remote Runtime sources and receive signed capability tokens. Repository-external Runtime files are not registration or trust authority.
Verify:
```bash
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
```
## 5. Start Runtime and Workspace Server
Start Runtime with the same storage flags used during Runtime identity/trust setup:
```bash
yoi-runtime \
--bind 127.0.0.1:38800
```
For repository builds, the equivalent cargo command is:
```bash
cargo run -p worker-runtime \
--bin yoi-runtime \
-- --bind 127.0.0.1:38800
```
Start Workspace Server:
```bash
yoi-server serve --listen 127.0.0.1:8787
```
For repository builds:
```bash
cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787
```
An empty Server DB is valid. Open the Web UI, create or authenticate the Account, and register the first Workspace through the normal Workspace creation flow. Server startup does not create a Workspace from its current working directory or repository-local configuration.
## Smoke checks
Check both trust stores:
```bash
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
yoi-runtime trust-server list --json
```
Check that Workspace Server can see Runtime workers through the authenticated path. From the CLI:
```bash
yoi workers \
--backend http://127.0.0.1:8787 \
--runtime-id runtime-main
```
In Web, open the Workspace UI through Workspace Server and verify that Runtime worker listing, worker creation, and Console protocol input work. The protocol WebSocket uses the same Server-signed Runtime auth path as REST control calls.
## Rotation and replacement
Identity and trust records are intentionally not overwritten by default.
Rotate Server identity:
```bash
yoi-server identity init --server-id server-main --replace
```
After Server identity rotation, every Runtime that trusts that Server must be updated with the new Server public key:
```bash
yoi-runtime trust-server add \
--server-id server-main \
--public-key '<NEW_SERVER_PUBLIC_KEY>' \
--replace
```
Rotate Runtime identity:
```bash
yoi-runtime identity init --runtime-id runtime-main --replace
```
After Runtime identity rotation, Server must be updated with the new Runtime public key:
```bash
yoi-server trust-runtime add \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main \
--base-url http://127.0.0.1:38800 \
--public-key '<NEW_RUNTIME_PUBLIC_KEY>' \
--replace
```
## Revocation
Revoke a trusted Runtime on Server:
```bash
yoi-server trust-runtime revoke \
--workspace-id '<WORKSPACE_ID>' \
--runtime-id runtime-main
```
Remove a trusted Server from Runtime:
```bash
yoi-runtime trust-server revoke --server-id server-main
```
## Troubleshooting
### `trusted runtimes are registered but server identity is not initialized`
The Server DB contains trusted Runtime records, but the Server signing identity file does not exist. Run:
```bash
yoi-server identity init --server-id server-main
```
If the identity was created in another environment, ensure the Server process is using the same Yoi data directory.
### Runtime accepts unauthenticated requests
Runtime only enables signed capability-token auth when both a Runtime identity and at least one trusted Server are present in its auth file. Check:
```bash
yoi-runtime identity show --json
yoi-runtime trust-server list --json
```
Also confirm the Runtime process was started with the same `--fs-root` / `--fs-runtime-dir` used for setup.
### Wrong audience or unauthorized Runtime response
Confirm the `--runtime-id` registered on Server exactly matches the Runtime identity id:
```bash
yoi-runtime identity show --json
yoi-server trust-runtime list --workspace-id '<WORKSPACE_ID>' --json
```
`RUNTIME_ID` is the token audience; mismatches are rejected by Runtime.
### Duplicate registration fails
This is expected. Use `--replace` only when intentionally rotating or updating trust material.
Server / Runtime の再起動は live reload ではない authority 変更を反映するときだけ、通常の運用権限と migration gate に従って行う。実行中プロセスを開発 Worker が無断で停止してはならない。
@@ -49,4 +49,4 @@ The corrupted `tool_result` was manually replaced with a synthetic repair record
## Related fixes made during investigation
- Added safer SSE parse diagnostics in `agen` so future provider-stream failures include HTTP status and selected safe response headers.
- Enabled local trace via `.yoi/override.local.toml` and manually set `record_event_trace = true` in the `yoi-orchestrator` metadata snapshot for future restores.
- Enabled local trace through the workspace configuration used at the time and manually set `record_event_trace = true` in the `yoi-orchestrator` metadata snapshot for future restores.
+9 -2
View File
@@ -33,9 +33,16 @@ context_window = 256000
# context window on this route, even when public API docs advertise larger
# model-family windows.
# Codex currently caps the ChatGPT-backed route at 272k even though the public
# GPT-5.6 Sol API model advertises a 1.05M context window. Keep both values so
# resolution and compaction use the effective backend limit without losing the
# GPT-6 Astra and GPT-5.6 Sol advertise a 1.05M context window. Keep both values so
# resolution and compaction use the effective backend limit without losing each
# underlying model capability.
[[model]]
id = "gpt-6-astra"
provider = "codex-oauth"
context_window = 1050000
max_context_window = 272000
capability = { tool_calling = "parallel", structured_output = "json_schema", reasoning = "effort", vision = true, prompt_caching = { kind = "auto" } }
[[model]]
id = "gpt-5.6-sol"
provider = "codex-oauth"
-301
View File
@@ -1,301 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Starts production Server/Runtime binaries against disposable state and ports.
# This script must never read or write the caller's Yoi data/config directories.
repo_root=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
server_bin=${YOI_SMOKE_SERVER_BIN:-"$repo_root/target/debug/yoi-server"}
runtime_bin=${YOI_SMOKE_RUNTIME_BIN:-"$repo_root/target/debug/yoi-runtime"}
server_port=${YOI_SMOKE_SERVER_PORT:-48787}
runtime_port=${YOI_SMOKE_RUNTIME_PORT:-48800}
keep=${YOI_SMOKE_KEEP:-0}
fail() {
printf 'isolated-startup-smoke: %s\n' "$*" >&2
exit 1
}
for command in curl git node ss; do
command -v "$command" >/dev/null || fail "required command is unavailable: $command"
done
[[ -x "$server_bin" ]] || fail "Server binary is not executable: $server_bin"
[[ -x "$runtime_bin" ]] || fail "Runtime binary is not executable: $runtime_bin"
[[ "$server_port" =~ ^[0-9]+$ ]] || fail "invalid Server port: $server_port"
[[ "$runtime_port" =~ ^[0-9]+$ ]] || fail "invalid Runtime port: $runtime_port"
[[ "$server_port" != "$runtime_port" ]] || fail "Server and Runtime ports must differ"
port_is_listening() {
local port=$1
ss -H -ltn "sport = :$port" | grep -q .
}
port_is_listening "$server_port" && fail "Server smoke port is already in use: $server_port"
port_is_listening "$runtime_port" && fail "Runtime smoke port is already in use: $runtime_port"
root=$(mktemp -d "${TMPDIR:-/tmp}/yoi-isolated-startup-smoke.XXXXXX")
server_pid=
runtime_pid=
stop_pid() {
local pid=${1:-}
[[ -n "$pid" ]] || return 0
kill -TERM "$pid" 2>/dev/null || true
for _ in $(seq 1 50); do
kill -0 "$pid" 2>/dev/null || break
sleep 0.1
done
if kill -0 "$pid" 2>/dev/null; then
kill -KILL "$pid" 2>/dev/null || true
fi
wait "$pid" 2>/dev/null || true
}
cleanup() {
local status=$?
trap - EXIT INT TERM
stop_pid "$runtime_pid"
stop_pid "$server_pid"
if [[ "$keep" == 1 ]]; then
printf 'isolated-startup-smoke: kept artifacts at %s\n' "$root" >&2
elif [[ $status -eq 0 ]]; then
rm -rf "$root"
else
printf 'isolated-startup-smoke: failed; artifacts kept at %s\n' "$root" >&2
fi
exit "$status"
}
trap cleanup EXIT INT TERM
mkdir -p "$root/home" "$root/data" "$root/config" "$root/repository" "$root/logs"
export HOME="$root/home"
export XDG_DATA_HOME="$root/data"
export XDG_CONFIG_HOME="$root/config"
unset YOI_DATA_DIR YOI_CONFIG_HOME
# Fail closed if isolation variables no longer point below the disposable root.
case "$HOME:$XDG_DATA_HOME:$XDG_CONFIG_HOME" in
"$root"/*:"$root"/*:"$root"/*) ;;
*) fail "HOME/XDG isolation guard failed" ;;
esac
server_url="http://127.0.0.1:$server_port"
runtime_url="http://127.0.0.1:$runtime_port"
server_id=isolated-smoke-server
runtime_id=isolated-smoke-runtime
git -C "$root/repository" init -q
git -C "$root/repository" config user.email smoke@example.invalid
git -C "$root/repository" config user.name 'Yoi isolated smoke'
printf '# isolated smoke\n' >"$root/repository/README.md"
git -C "$root/repository" add README.md
git -C "$root/repository" commit -qm 'test: initialize isolated smoke repository'
"$server_bin" identity init --server-id "$server_id" >"$root/logs/server-identity-init.log" 2>&1
"$runtime_bin" identity init --runtime-id "$runtime_id" >"$root/logs/runtime-identity-init.log" 2>&1
server_identity=$("$server_bin" identity show --json)
runtime_identity=$("$runtime_bin" identity show --json)
server_key=$(printf '%s' "$server_identity" | node -e 'const fs=require("fs"); process.stdout.write(JSON.parse(fs.readFileSync(0,"utf8")).public_key)')
runtime_key=$(printf '%s' "$runtime_identity" | node -e 'const fs=require("fs"); process.stdout.write(JSON.parse(fs.readFileSync(0,"utf8")).public_key)')
"$server_bin" trust-runtime add \
--runtime-id "$runtime_id" \
--public-key "$runtime_key" \
--base-url "$runtime_url" >"$root/logs/server-trust-runtime.log" 2>&1
"$runtime_bin" trust-server add \
--server-id "$server_id" \
--public-key "$server_key" >"$root/logs/runtime-trust-server.log" 2>&1
"$server_bin" init --workspace "$root/repository" >"$root/logs/server-init.log" 2>&1
workspace_id=$(sed -n 's/^workspace_id = "\([^"]*\)"/\1/p' "$root/repository/.yoi/workspace.toml")
[[ -n "$workspace_id" ]] || fail "workspace init did not write workspace_id"
# Runtime Git materialization requires a clean source repository. Commit both
# local bootstrap markers inside this disposable repository.
git -C "$root/repository" add .yoi/workspace.toml .yoi/workspace-backend.local.toml
git -C "$root/repository" commit -qm 'test: record isolated Yoi workspace markers'
runtime_store="$XDG_DATA_HOME/yoi/runtime"
mkdir -p "$runtime_store/workers"
cat >"$runtime_store/runtime.json" <<'JSON'
{
"schema_version": 1,
"display_name": "isolated startup smoke",
"backend": "fs_store",
"status": "running",
"next_worker_sequence": 1,
"next_diagnostic_id": 1,
"config_bundles": {},
"workspace_owners": {},
"diagnostics": []
}
JSON
start_server() {
: >"$root/logs/server.log"
"$server_bin" serve --listen "127.0.0.1:$server_port" >"$root/logs/server.log" 2>&1 &
server_pid=$!
}
start_runtime() {
: >"$root/logs/runtime.log"
"$runtime_bin" --bind "127.0.0.1:$runtime_port" >"$root/logs/runtime.log" 2>&1 &
runtime_pid=$!
}
wait_for_listener() {
local pid=$1
local port=$2
local name=$3
for _ in $(seq 1 150); do
kill -0 "$pid" 2>/dev/null || fail "$name exited before listening; inspect $root/logs"
port_is_listening "$port" && return 0
sleep 0.1
done
fail "$name did not listen on port $port within 15 seconds"
}
runtime_projection() {
curl --fail --silent --show-error \
"$server_url/api/w/$workspace_id/runtimes"
}
projection_is_ready() {
node -e '
const fs = require("fs");
const runtimeId = process.argv[1];
const body = JSON.parse(fs.readFileSync(0, "utf8"));
const runtime = body.items.find((item) => item.runtime_id === runtimeId);
if (!runtime || runtime.status !== "running") process.exit(1);
if (!runtime.capabilities?.can_list_workers) process.exit(1);
if ((runtime.diagnostics ?? []).length !== 0) process.exit(1);
if ((body.diagnostics ?? []).length !== 0) process.exit(1);
' "$runtime_id"
}
wait_for_projection_state() {
local expected=$1
local body=
for _ in $(seq 1 150); do
kill -0 "$server_pid" 2>/dev/null || fail "Server exited during readiness check"
body=$(runtime_projection 2>/dev/null || true)
if [[ -n "$body" ]]; then
if printf '%s' "$body" | projection_is_ready 2>/dev/null; then
[[ "$expected" == ready ]] && return 0
else
[[ "$expected" == not-ready ]] && return 0
fi
fi
sleep 0.1
done
printf '%s\n' "$body" >"$root/logs/last-runtime-projection.json"
fail "Runtime projection did not become $expected within 15 seconds"
}
assert_clean_logs() {
if grep -Eiq 'panicked at|thread .* panicked|UNIQUE constraint failed|worker_execution_restore_failed' \
"$root/logs/server.log" "$root/logs/runtime.log"; then
fail "panic, migration collision, or restore failure found in startup logs"
fi
}
start_server
wait_for_listener "$server_pid" "$server_port" Server
# Negative control: a listening Server is not readiness. The configured remote
# Runtime must be rejected while it is absent.
wait_for_projection_state not-ready
start_runtime
wait_for_listener "$runtime_pid" "$runtime_port" Runtime
wait_for_projection_state ready
assert_clean_logs
# Listener/catalog readiness is insufficient. Materialize a real Workdir and
# require the normal Server -> Runtime Worker spawn path to create a persisted
# Worker with an execution handle. This catches adapter panics that startup
# alone cannot observe.
repositories=$(curl --fail --silent --show-error \
"$server_url/api/w/$workspace_id/repositories")
repository_id=$(printf '%s' "$repositories" | node -e '
const fs = require("fs");
const body = JSON.parse(fs.readFileSync(0, "utf8"));
if (body.items.length !== 1) process.exit(1);
process.stdout.write(body.items[0].id);
')
workdir_response=$(curl --fail --silent --show-error \
--request POST \
--header 'content-type: application/json' \
--data "{\"runtime_id\":\"$runtime_id\",\"repository_id\":\"$repository_id\"}" \
"$server_url/api/w/$workspace_id/runtimes/$runtime_id/working-directories") || \
fail "isolated Workdir materialization failed"
working_directory_id=$(printf '%s' "$workdir_response" | node -e '
const fs = require("fs");
const body = JSON.parse(fs.readFileSync(0, "utf8"));
if (body.item?.status !== "active" || body.item?.cleanliness !== "clean") process.exit(1);
process.stdout.write(body.item.working_directory_id);
')
cat >"$root/worker-create.json" <<JSON
{
"runtime_id": "$runtime_id",
"display_name": "isolated restore smoke",
"profile": "builtin:companion",
"initial_submit": [],
"working_directory": {
"working_directory_id": "$working_directory_id"
}
}
JSON
worker_response=$(curl --fail --silent --show-error \
--request POST \
--header 'content-type: application/json' \
--data @"$root/worker-create.json" \
"$server_url/api/w/$workspace_id/workers") || \
fail "isolated Worker spawn failed; listener readiness is not sufficient"
worker_id=$(printf '%s' "$worker_response" | node -e '
const fs = require("fs");
const body = JSON.parse(fs.readFileSync(0, "utf8"));
if (body.runtime_id !== process.argv[1] || !body.worker_id) process.exit(1);
process.stdout.write(String(body.worker_id));
' "$runtime_id")
node -e '
const fs = require("fs");
const record = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
const expectedBase = process.argv[2];
const profileUrl = record.request?.profile_source?.location?.url;
const workspaceUrl = record.request?.workspace_api?.base_url;
if (!profileUrl?.startsWith(`${expectedBase}/`)) {
console.error(`profile callback escaped isolated Server: ${profileUrl}`);
process.exit(1);
}
if (workspaceUrl !== expectedBase) {
console.error(`Workspace API escaped isolated Server: ${workspaceUrl}`);
process.exit(1);
}
' "$runtime_store/workers/$worker_id/worker.json" "$server_url" || \
fail "persisted Worker callback URLs are not isolated"
assert_clean_logs
# Exercise persistence reopen with a real persisted Worker and require the
# Server projection to recover. The Worker record must remain addressable after
# Runtime restart; restore failures and adapter panics are rejected by log scan.
stop_pid "$runtime_pid"
runtime_pid=
wait_for_projection_state not-ready
start_runtime
wait_for_listener "$runtime_pid" "$runtime_port" Runtime
wait_for_projection_state ready
curl --fail --silent --show-error \
"$server_url/api/w/$workspace_id/runtimes/$runtime_id/workers/$worker_id" \
>"$root/logs/restored-worker.json" || fail "persisted Worker is unavailable after Runtime restart"
assert_clean_logs
# Prove that this run used only disposable state paths.
grep -Fq "$root/data/yoi/server/server.db" "$root/logs/server.log" || \
fail "Server log does not identify the isolated database"
if grep -Fq '/home/hare/.local/share/yoi' "$root/logs/server.log" "$root/logs/runtime.log"; then
fail "startup logs reference a non-isolated Yoi data path"
fi
printf 'isolated-startup-smoke: PASS (workspace=%s, server=%s, runtime=%s)\n' \
"$workspace_id" "$server_url" "$runtime_url"
+1 -1
View File
@@ -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 src/lib/workspace/console/composer-delivery.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",
"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 src/lib/workspace/console/composer-delivery.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 test/repository-ssh-connection-ui.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},
@@ -22,6 +22,20 @@ export type CreateRepositorySshCredentialRequest = {
passphrase: string | null;
};
export type GenerateRepositorySshCredentialRequest = {
operation_id: string;
credential_id: string;
name: string;
};
export type RepositorySshPublicKey = {
credential_id: string;
current_revision: number;
public_key_algorithm: string;
public_key_fingerprint: string;
public_key: string;
};
export type RotateRepositorySshCredentialRequest = {
operation_id: string;
expected_revision: number;
@@ -9,9 +9,7 @@ export type Diagnostic = {
message: string;
};
export type WorkingDirectoryMaterializerKind =
| "runtime_git_cache"
| "local_git_worktree";
export type WorkingDirectoryMaterializerKind = "runtime_git_clone";
export type WorkingDirectoryStatusKind =
| "active"
@@ -11,9 +11,7 @@ export type Diagnostic = {
message: string;
};
export type WorkingDirectoryMaterializerKind =
| "runtime_git_cache"
| "local_git_worktree";
export type WorkingDirectoryMaterializerKind = "runtime_git_clone";
export type WorkingDirectoryStatusKind =
| "active"
@@ -165,6 +165,35 @@ export type WorkspaceMetadataMutationResponse = {
diagnostics: Array<Diagnostic>;
};
export type WorkspaceSigningIdentityState = "pending_provisioning" | "active";
export type WorkspaceSigningIdentityPublic = {
workspace_id: string;
key_id: string;
algorithm: string;
public_key?: string;
public_key_fingerprint?: string;
revision: number;
state: WorkspaceSigningIdentityState;
created_at: string;
provisioned_at?: string;
};
export type WorkspacePublicIdentityBundle = {
workspace_id: string;
backend_url: string;
key_id: string;
algorithm: string;
public_key: string;
public_key_fingerprint: string;
revision: number;
};
export type WorkspaceSigningIdentityResponse = {
identity: WorkspaceSigningIdentityPublic;
public_bundle?: WorkspacePublicIdentityBundle;
};
export type ProfileSettingsResponse = {
workspace_id: string;
registry_revision: string;
@@ -278,6 +307,38 @@ export type RepositoryDetailResponse = {
source: string;
};
export type RepositorySshConnectionProbeRequest = { runtime_id: string };
export type RepositorySshHostKeyCandidate = {
algorithm: string;
host_key: string;
fingerprint: string;
};
export type RepositorySshConnectionTrustState =
| "untrusted"
| "verified"
| "changed";
export type RepositorySshConnectionProbeResponse = {
workspace_id: string;
repository_key: string;
runtime_id: string;
hostname: string;
port: number;
trust_state: RepositorySshConnectionTrustState;
host_trust_id: string;
expected_host_trust_revision: number | null;
candidates: Array<RepositorySshHostKeyCandidate>;
};
export type ConfirmRepositorySshHostTrustRequest = {
operation_id: string;
runtime_id: string;
host_key: string;
expected_host_trust_revision: number | null;
};
export type RepositoryLogResponse = {
workspace_id: string;
repository_key: string;
@@ -315,12 +376,51 @@ export type RuntimeSummary = {
diagnostics: Array<Diagnostic>;
};
export type WorkspaceRuntimeBindingState =
| "configured"
| "verified"
| "revoked";
export type RuntimeConnectionDisplayState =
| "configured"
| "verified"
| "unavailable"
| "revoked";
export type RuntimeVerificationOutcome =
| "verified"
| "challenge_issued"
| "verification_failed"
| "connectivity_failed";
export type RuntimeVerificationEvidenceSummary = {
verified_at: string | null;
last_checked_at: string;
last_outcome: RuntimeVerificationOutcome;
binding_revision: number;
workspace_key_id: string;
workspace_identity_revision: number;
workspace_trust_generation: number;
runtime_public_key_fingerprint: string;
runtime_identity_revision: number;
};
export type WorkspaceRuntimeBindingSummary = {
state: WorkspaceRuntimeBindingState;
connection_state: RuntimeConnectionDisplayState;
revision: number;
workspace_key_id?: string | null;
workspace_key_generation?: number | null;
verification?: RuntimeVerificationEvidenceSummary | null;
};
export type RuntimeManagementSummary = {
built_in: boolean;
config_managed: boolean;
removable: boolean;
endpoint_configured: boolean;
token_ref_configured: boolean;
binding?: WorkspaceRuntimeBindingSummary | null;
};
export type WorkspaceRuntimeResource = {
@@ -373,11 +473,6 @@ export type WorkspaceRuntimeDetail = {
export type RuntimeTrustKeyRevealResponse = { public_key: string };
export type PutRuntimeTrustKeyRequest = {
public_key: string;
expected_revision: number | null;
};
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
@@ -389,6 +484,18 @@ export type RuntimeTrustConflictResponse = {
current_fingerprint?: string | null;
};
export type RuntimePublicIdentityBundle = {
identity_id: string;
public_key: string;
};
export type CreateRemoteRuntimeRequest = {
public_bundle: RuntimePublicIdentityBundle;
display_name?: string | null;
endpoint: string;
expected_revision?: number | null;
};
export type RuntimeConnectionTestStatus = "compatible" | "failed";
export type RuntimeConnectionTestFailureKind =
@@ -405,6 +512,9 @@ export type RuntimeConnectionTestFailureKind =
export type RuntimeConnectionTestResponse = {
workspace_id: string;
runtime_id: string;
binding_revision: number;
connection_state: RuntimeConnectionDisplayState;
verification: RuntimeVerificationEvidenceSummary | null;
checked_at: string;
status: RuntimeConnectionTestStatus;
failure_kind: RuntimeConnectionTestFailureKind | null;
@@ -2,6 +2,7 @@ import type {
RepositoryAccessProjection,
RepositorySshCredential,
RepositorySshHostTrust,
RepositorySshPublicKey,
} from "../../generated/repository-access-api.ts";
export class RepositoryAccessSchemaError extends Error {
@@ -50,6 +51,25 @@ export function parseRepositorySshCredential(
return record as RepositorySshCredential;
}
export function parseRepositorySshPublicKey(
value: unknown,
path = "public_key",
): RepositorySshPublicKey {
const record = readRecord(value, path, [
"credential_id",
"current_revision",
"public_key_algorithm",
"public_key_fingerprint",
"public_key",
]);
readString(record, "credential_id", path);
readRevision(record, "current_revision", path);
readString(record, "public_key_algorithm", path);
readString(record, "public_key_fingerprint", path);
readString(record, "public_key", path);
return record as RepositorySshPublicKey;
}
export function parseRepositorySshHostTrusts(
value: unknown,
): RepositorySshHostTrust[] {
@@ -2,11 +2,15 @@ import type {
Diagnostic,
RuntimeConnectionTestFailureKind,
RuntimeConnectionTestResponse,
RuntimeVerificationEvidenceSummary,
} from "$lib/generated/workspace-api";
const RESPONSE_KEYS = [
"workspace_id",
"runtime_id",
"binding_revision",
"connection_state",
"verification",
"checked_at",
"status",
"failure_kind",
@@ -103,9 +107,28 @@ export function parseRuntimeConnectionTestResponse(
) {
return null;
}
const bindingRevision = value.binding_revision;
const connectionState = parseConnectionState(value.connection_state);
const verification = parseVerificationEvidence(value.verification);
if (
!isSafeRevision(bindingRevision) ||
connectionState === null ||
(value.verification !== null && verification === null) ||
(verification !== null &&
verification.binding_revision !== bindingRevision) ||
(connectionState === "verified" && value.status !== "compatible") ||
(connectionState === "verified" && verification !== null &&
(verification.last_outcome !== "verified" ||
verification.verified_at === null))
) {
return null;
}
return {
workspace_id: value.workspace_id,
runtime_id: value.runtime_id,
binding_revision: bindingRevision,
connection_state: connectionState,
verification,
checked_at: value.checked_at,
status: value.status,
failure_kind: failureKind as RuntimeConnectionTestFailureKind | null,
@@ -115,6 +138,67 @@ export function parseRuntimeConnectionTestResponse(
};
}
function parseConnectionState(
value: unknown,
): "configured" | "verified" | "unavailable" | "revoked" | null {
return value === "configured" || value === "verified" ||
value === "unavailable" || value === "revoked"
? value
: null;
}
function isSafeRevision(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function parseVerificationEvidence(
value: unknown,
): RuntimeVerificationEvidenceSummary | null {
if (value === null) return null;
const keys = [
"verified_at",
"last_checked_at",
"last_outcome",
"binding_revision",
"workspace_key_id",
"workspace_identity_revision",
"workspace_trust_generation",
"runtime_public_key_fingerprint",
"runtime_identity_revision",
] as const;
if (!isRecord(value) || !hasExactKeys(value, keys)) return null;
if (
(value.verified_at !== null &&
(!isBoundedString(value.verified_at, 128) ||
Number.isNaN(Date.parse(value.verified_at)))) ||
!isBoundedString(value.last_checked_at, 128) ||
Number.isNaN(Date.parse(value.last_checked_at)) ||
(value.last_outcome !== "verified" &&
value.last_outcome !== "challenge_issued" &&
value.last_outcome !== "verification_failed" &&
value.last_outcome !== "connectivity_failed") ||
!isSafeRevision(value.binding_revision) ||
!isBoundedString(value.workspace_key_id, 128) ||
!isSafeRevision(value.workspace_identity_revision) ||
!isSafeRevision(value.workspace_trust_generation) ||
!isBoundedString(value.runtime_public_key_fingerprint, 128) ||
!isSafeRevision(value.runtime_identity_revision)
) {
return null;
}
return {
verified_at: value.verified_at,
last_checked_at: value.last_checked_at,
last_outcome: value.last_outcome,
binding_revision: value.binding_revision,
workspace_key_id: value.workspace_key_id,
workspace_identity_revision: value.workspace_identity_revision,
workspace_trust_generation: value.workspace_trust_generation,
runtime_public_key_fingerprint: value.runtime_public_key_fingerprint,
runtime_identity_revision: value.runtime_identity_revision,
};
}
export async function testRuntimeConnection(
workspaceId: string,
runtimeId: string,
@@ -1,7 +1,8 @@
import type {
CreateRemoteRuntimeRequest,
Diagnostic,
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeConnectionDisplayState,
RuntimeIdentityAuthority,
RuntimeManagementSummary,
RuntimeSourceKind,
@@ -14,6 +15,9 @@ import type {
RuntimeTrustKeyRevealResponse,
RuntimeTrustKeyState,
RuntimeTrustKeyStatus,
RuntimeVerificationEvidenceSummary,
WorkspaceRuntimeBindingState,
WorkspaceRuntimeBindingSummary,
WorkspaceRuntimeDetail,
WorkspaceRuntimeResource,
} from "$lib/generated/workspace-api.ts";
@@ -67,6 +71,17 @@ const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
"stale_revision",
"fingerprint_in_use",
]);
const BINDING_STATES = new Set<WorkspaceRuntimeBindingState>([
"configured",
"verified",
"revoked",
]);
const CONNECTION_STATES = new Set<RuntimeConnectionDisplayState>([
"configured",
"verified",
"unavailable",
"revoked",
]);
const encoder = new TextEncoder();
type JsonObject = Record<string, unknown>;
@@ -286,6 +301,148 @@ function runtimeSource(value: unknown, path: string): RuntimeSourceSummary {
};
}
function runtimeVerification(
value: unknown,
path: string,
): RuntimeVerificationEvidenceSummary {
const item = object(value, path);
exactKeys(
item,
[
"verified_at",
"last_checked_at",
"last_outcome",
"binding_revision",
"workspace_key_id",
"workspace_identity_revision",
"workspace_trust_generation",
"runtime_public_key_fingerprint",
"runtime_identity_revision",
],
[],
path,
);
const verifiedAt = item.verified_at === null
? null
: boundedString(item.verified_at, `${path}.verified_at`, 128);
const lastOutcome = enumValue(
item.last_outcome,
`${path}.last_outcome`,
new Set(
[
"verified",
"challenge_issued",
"verification_failed",
"connectivity_failed",
] as const,
),
);
return {
verified_at: verifiedAt,
last_checked_at: boundedString(
item.last_checked_at,
`${path}.last_checked_at`,
128,
),
last_outcome: lastOutcome,
binding_revision: safeRevision(
item.binding_revision,
`${path}.binding_revision`,
),
workspace_key_id: boundedString(
item.workspace_key_id,
`${path}.workspace_key_id`,
LIMITS.idBytes,
),
workspace_identity_revision: safeRevision(
item.workspace_identity_revision,
`${path}.workspace_identity_revision`,
),
workspace_trust_generation: safeRevision(
item.workspace_trust_generation,
`${path}.workspace_trust_generation`,
),
runtime_public_key_fingerprint: boundedString(
item.runtime_public_key_fingerprint,
`${path}.runtime_public_key_fingerprint`,
LIMITS.fingerprintBytes,
),
runtime_identity_revision: safeRevision(
item.runtime_identity_revision,
`${path}.runtime_identity_revision`,
),
};
}
function runtimeBinding(
value: unknown,
path: string,
requiresWorkspaceIdentity: boolean,
): WorkspaceRuntimeBindingSummary {
const item = object(value, path);
exactKeys(
item,
["state", "connection_state", "revision"],
["workspace_key_id", "workspace_key_generation", "verification"],
path,
);
const workspaceKeyId = optionalNullableString(
item.workspace_key_id,
`${path}.workspace_key_id`,
LIMITS.idBytes,
);
const workspaceKeyGeneration = optionalNullableRevision(
item.workspace_key_generation,
`${path}.workspace_key_generation`,
);
const state = enumValue(item.state, `${path}.state`, BINDING_STATES);
if (
requiresWorkspaceIdentity &&
state !== "revoked" &&
(workspaceKeyId == null || workspaceKeyGeneration == null)
) {
return fail(path, "requires Workspace signing key identity metadata");
}
const connectionState = enumValue(
item.connection_state,
`${path}.connection_state`,
CONNECTION_STATES,
);
const revision = safeRevision(item.revision, `${path}.revision`);
const verification = item.verification === undefined
? undefined
: runtimeVerification(item.verification, `${path}.verification`);
if (
verification !== undefined && verification.binding_revision !== revision
) {
return fail(path, "verification must match the current binding revision");
}
if (
requiresWorkspaceIdentity &&
connectionState === "verified" &&
(verification === undefined ||
verification.verified_at === null ||
verification.last_outcome !== "verified")
) {
return fail(
path,
"verified Workspace identity binding requires verification evidence",
);
}
return {
state,
connection_state: connectionState,
revision,
...(workspaceKeyId === undefined
? {}
: { workspace_key_id: workspaceKeyId }),
...(workspaceKeyGeneration === undefined
? {}
: { workspace_key_generation: workspaceKeyGeneration }),
...(verification === undefined ? {} : { verification }),
};
}
function runtimeManagement(
value: unknown,
path: string,
@@ -300,11 +457,15 @@ function runtimeManagement(
"endpoint_configured",
"token_ref_configured",
],
[],
["binding"],
path,
);
const builtIn = boolean(item.built_in, `${path}.built_in`);
const binding = item.binding == null
? undefined
: runtimeBinding(item.binding, `${path}.binding`, !builtIn);
return {
built_in: boolean(item.built_in, `${path}.built_in`),
built_in: builtIn,
config_managed: boolean(item.config_managed, `${path}.config_managed`),
removable: boolean(item.removable, `${path}.removable`),
endpoint_configured: boolean(
@@ -315,6 +476,7 @@ function runtimeManagement(
item.token_ref_configured,
`${path}.token_ref_configured`,
),
...(binding === undefined ? {} : { binding }),
};
}
@@ -648,6 +810,26 @@ function requestErrorFrom(
): RuntimeTrustRequestError {
try {
const response = object(value, "Runtime trust error");
if ("details" in response) {
exactKeys(
response,
["error", "details"],
[],
"Runtime trust error",
);
boundedString(
response.error,
"Runtime trust error.error",
LIMITS.idBytes,
);
return new RuntimeTrustRequestError(
boundedString(
response.details,
"Runtime trust error.details",
LIMITS.conflictMessageBytes,
),
);
}
exactKeys(
response,
["error", "message", "diagnostics"],
@@ -713,6 +895,54 @@ async function finishMutation(
return detail;
}
export async function createRemoteRuntime(
workspaceId: string,
request: CreateRemoteRuntimeRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeResource> {
const response = await fetchImpl(
workspaceApiPath(workspaceId, "/runtimes"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
);
const payload = await readBoundedJson(response);
if (!response.ok) throw requestErrorFrom(payload, response.status);
const runtime = runtimeResource(payload, "Runtime create response");
if (runtime.runtime_id !== request.public_bundle.identity_id) {
throw new RuntimeTrustRequestError(
"Runtime create response did not match the submitted public bundle",
);
}
return runtime;
}
export async function deleteRemoteRuntime(
workspaceId: string,
runtimeId: string,
fetchImpl: typeof fetch = fetch,
): Promise<void> {
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}`,
),
{ method: "DELETE" },
);
if (response.ok) return;
let payload: unknown;
try {
payload = await readBoundedJson(response);
} catch {
throw new RuntimeTrustRequestError(
`Runtime registration delete failed (${response.status})`,
);
}
throw requestErrorFrom(payload, response.status);
}
export async function revealRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
@@ -763,29 +993,6 @@ export async function previewRuntimePublicKeyFingerprint(
return `sha256:${hex}`;
}
export async function putRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
request: PutRuntimeTrustKeyRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeDetail> {
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
),
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
public_key: request.public_key,
expected_revision: revisionForJson(request.expected_revision),
}),
},
);
return await finishMutation(response, workspaceId, runtimeId);
}
export async function revokeRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
@@ -114,8 +114,7 @@ export function parseWorkingDirectorySummary(
working_directory_id: stringField(record, "working_directory_id"),
repository_key: stringField(record, "repository_key"),
materializer_kind: enumField(record, "materializer_kind", [
"runtime_git_cache",
"local_git_worktree",
"runtime_git_clone",
]),
status: enumField(record, "status", [
"active",
@@ -282,10 +282,7 @@ function runtimeWorkingDirectory(
item.materializer_kind,
`${label}.materializer_kind`,
);
if (
materializerKind !== "runtime_git_cache" &&
materializerKind !== "local_git_worktree"
) {
if (materializerKind !== "runtime_git_clone") {
throw new Error(`${label}.materializer_kind is invalid`);
}
const status = string(item.status, `${label}.status`);
@@ -10,6 +10,9 @@ import type {
RepositoryLogResponse,
RepositorySource,
RepositorySourceKind,
RepositorySshConnectionProbeResponse,
RepositorySshConnectionTrustState,
RepositorySshHostKeyCandidate,
RepositorySummary,
WorkspaceAuthConfig,
WorkspaceCatalogListResponse,
@@ -35,6 +38,8 @@ export type {
RepositoryDetailResponse,
RepositoryListResponse,
RepositoryLogResponse,
RepositorySshConnectionProbeResponse,
RepositorySshHostKeyCandidate,
RepositorySummary,
WorkspaceCatalogListResponse,
WorkspaceCreateResponse,
@@ -583,6 +588,98 @@ export function parseRepositoryDetailResponse(
};
}
const SSH_CONNECTION_TRUST_STATES = new Set<RepositorySshConnectionTrustState>([
"untrusted",
"verified",
"changed",
]);
function repositorySshHostKeyCandidate(
value: unknown,
path: string,
): RepositorySshHostKeyCandidate {
const candidate = object(value, path);
exactKeys(candidate, ["algorithm", "host_key", "fingerprint"], path);
return {
algorithm: string(candidate.algorithm, `${path}.algorithm`),
host_key: string(candidate.host_key, `${path}.host_key`),
fingerprint: string(candidate.fingerprint, `${path}.fingerprint`),
};
}
export function parseRepositorySshConnectionProbeResponse(
value: unknown,
): RepositorySshConnectionProbeResponse {
const response = object(value, "repository SSH connection probe response");
exactKeys(
response,
[
"workspace_id",
"repository_key",
"runtime_id",
"hostname",
"port",
"trust_state",
"host_trust_id",
"expected_host_trust_revision",
"candidates",
],
"repository SSH connection probe response",
);
const trustState = string(
response.trust_state,
"repository SSH connection probe response.trust_state",
) as RepositorySshConnectionTrustState;
if (!SSH_CONNECTION_TRUST_STATES.has(trustState)) {
throw new Error(
"repository SSH connection probe response.trust_state is invalid",
);
}
return {
workspace_id: string(
response.workspace_id,
"repository SSH connection probe response.workspace_id",
),
repository_key: string(
response.repository_key,
"repository SSH connection probe response.repository_key",
),
runtime_id: string(
response.runtime_id,
"repository SSH connection probe response.runtime_id",
),
hostname: string(
response.hostname,
"repository SSH connection probe response.hostname",
),
port: integer(
response.port,
"repository SSH connection probe response.port",
),
trust_state: trustState,
host_trust_id: string(
response.host_trust_id,
"repository SSH connection probe response.host_trust_id",
),
expected_host_trust_revision: response.expected_host_trust_revision === null
? null
: integer(
response.expected_host_trust_revision,
"repository SSH connection probe response.expected_host_trust_revision",
),
candidates: array(
response.candidates,
"repository SSH connection probe response.candidates",
).map(
(candidate, index) =>
repositorySshHostKeyCandidate(
candidate,
`repository SSH connection probe response.candidates[${index}]`,
),
),
};
}
const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES = 128;
const WORKSPACE_DELETION_MAX_REVISION_BYTES = 128;
const WORKSPACE_DELETION_MAX_BLOCKERS = 1024;
@@ -678,7 +678,7 @@ Deno.test("workspace Runtime inventory lives under Settings admin routes", async
"Runtimes should be admin Settings navigation, not primary workspace sidebar navigation",
);
assert(
runtimesPage.includes("Add remote Runtime") &&
runtimesPage.includes("Connect a remote Runtime") &&
runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("settings-runtime-table") &&
runtimesPage.includes("testRuntimeConnection") &&
@@ -0,0 +1,13 @@
import type { WorkspaceRuntimeResource } from "$lib/generated/workspace-api";
export function repositorySshProbeRuntimes(
runtimes: readonly WorkspaceRuntimeResource[],
): WorkspaceRuntimeResource[] {
return runtimes.filter((runtime) =>
runtime.kind === "remote_worker_runtime" &&
runtime.management.endpoint_configured &&
runtime.management.binding !== undefined &&
runtime.management.binding !== null &&
runtime.management.binding.state !== "revoked"
);
}
@@ -24,6 +24,10 @@ Deno.test("settings section navigation stays under the settings route", () => {
settingsSectionHref("configuration-sources") === "/settings/configuration",
"shared configuration editor route should stay canonical",
);
assert(
settingsSectionHref("workspace-identity") === "/settings",
"Workspace identity should use the settings root without a redundant workspace segment",
);
for (const section of SETTINGS_SECTIONS) {
const href = settingsSectionHref(section.id);
@@ -54,7 +58,9 @@ Deno.test("settings shell advertises scoped account authority", () => {
});
Deno.test("Repository settings expose the canonical list and Add route", () => {
const section = SETTINGS_SECTIONS.find((entry) => entry.id === "repositories");
const section = SETTINGS_SECTIONS.find((entry) =>
entry.id === "repositories"
);
assert(section?.status === "editable", "Repositories should be editable");
assert(
settingsSectionHref("repositories") === "/settings/repositories",
@@ -134,7 +134,7 @@ export function settingsSectionHref(id: SettingsSectionId): string {
case "profile-sources":
return `${SETTINGS_ROUTE}/profiles`;
case "workspace-identity":
return `${SETTINGS_ROUTE}/workspace`;
return SETTINGS_ROUTE;
}
}
@@ -8,6 +8,10 @@ import type {
WorkspaceProfileSourceProvenance,
WorkspaceProfileSourceSummary,
WorkspaceProfileSummary,
WorkspacePublicIdentityBundle,
WorkspaceSigningIdentityPublic,
WorkspaceSigningIdentityResponse,
WorkspaceSigningIdentityState,
} from "$lib/generated/workspace-api";
export class ProfileApiError extends Error {
@@ -51,6 +55,18 @@ function stringValue(value: unknown, context: string): string {
return value;
}
function boundedStringValue(
value: unknown,
context: string,
maxBytes: number,
): string {
const text = stringValue(value, context);
if (new TextEncoder().encode(text).byteLength > maxBytes) {
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
}
return text;
}
function booleanValue(value: unknown, context: string): boolean {
if (typeof value !== "boolean") {
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
@@ -66,6 +82,15 @@ function optionalString(
return stringValue(value, context);
}
function optionalBoundedString(
value: unknown,
context: string,
maxBytes: number,
): string | null | undefined {
if (value === undefined || value === null) return value;
return boundedStringValue(value, context, maxBytes);
}
function optionalRevision(
value: unknown,
context: string,
@@ -286,6 +311,197 @@ export function parseProfileSettingsResponse(
};
}
export function parseWorkspaceSigningIdentityResponse(
value: unknown,
): WorkspaceSigningIdentityResponse {
const item = record(value, "Workspace signing identity");
exactKeys(
item,
["identity"],
["public_bundle"],
"Workspace signing identity",
);
const identityItem = record(item.identity, "Workspace signing identity");
exactKeys(
identityItem,
["workspace_id", "key_id", "algorithm", "revision", "state", "created_at"],
["public_key", "public_key_fingerprint", "provisioned_at"],
"Workspace signing identity",
);
const state = boundedStringValue(
identityItem.state,
"Workspace signing identity",
32,
);
if (state !== "pending_provisioning" && state !== "active") {
throw new ProfileApiError(
"Workspace signing identity returned an invalid response.",
502,
);
}
const revision = optionalRevision(
identityItem.revision,
"Workspace signing identity",
);
if (revision === undefined || revision === null || revision < 1) {
throw new ProfileApiError(
"Workspace signing identity returned an invalid response.",
502,
);
}
const publicKey = optionalBoundedString(
identityItem.public_key,
"Workspace signing identity",
256,
);
const fingerprint = optionalBoundedString(
identityItem.public_key_fingerprint,
"Workspace signing identity",
128,
);
const provisionedAt = optionalBoundedString(
identityItem.provisioned_at,
"Workspace signing identity",
128,
);
const identity: WorkspaceSigningIdentityPublic = {
workspace_id: boundedStringValue(
identityItem.workspace_id,
"Workspace signing identity",
128,
),
key_id: boundedStringValue(
identityItem.key_id,
"Workspace signing identity",
128,
),
algorithm: boundedStringValue(
identityItem.algorithm,
"Workspace signing identity",
32,
),
...(publicKey === undefined || publicKey === null
? {}
: { public_key: publicKey }),
...(fingerprint === undefined || fingerprint === null
? {}
: { public_key_fingerprint: fingerprint }),
revision,
state: state as WorkspaceSigningIdentityState,
created_at: boundedStringValue(
identityItem.created_at,
"Workspace signing identity",
128,
),
...(provisionedAt === undefined || provisionedAt === null
? {}
: { provisioned_at: provisionedAt }),
};
let publicBundle: WorkspacePublicIdentityBundle | undefined;
if (item.public_bundle !== undefined) {
const bundle = record(
item.public_bundle,
"Workspace public identity bundle",
);
exactKeys(
bundle,
[
"workspace_id",
"backend_url",
"key_id",
"algorithm",
"public_key",
"public_key_fingerprint",
"revision",
],
[],
"Workspace public identity bundle",
);
const bundleRevision = optionalRevision(
bundle.revision,
"Workspace public identity bundle",
);
if (
bundleRevision === undefined || bundleRevision === null ||
bundleRevision < 1
) {
throw new ProfileApiError(
"Workspace public identity bundle returned an invalid response.",
502,
);
}
publicBundle = {
workspace_id: boundedStringValue(
bundle.workspace_id,
"Workspace public identity bundle",
128,
),
backend_url: boundedStringValue(
bundle.backend_url,
"Workspace public identity bundle",
2048,
),
key_id: boundedStringValue(
bundle.key_id,
"Workspace public identity bundle",
128,
),
algorithm: boundedStringValue(
bundle.algorithm,
"Workspace public identity bundle",
32,
),
public_key: boundedStringValue(
bundle.public_key,
"Workspace public identity bundle",
256,
),
public_key_fingerprint: boundedStringValue(
bundle.public_key_fingerprint,
"Workspace public identity bundle",
128,
),
revision: bundleRevision,
};
}
if (
publicBundle !== undefined &&
(
publicBundle.workspace_id !== identity.workspace_id ||
publicBundle.key_id !== identity.key_id ||
publicBundle.algorithm !== identity.algorithm ||
publicBundle.public_key !== identity.public_key ||
publicBundle.public_key_fingerprint !== identity.public_key_fingerprint ||
publicBundle.revision !== identity.revision
)
) {
throw new ProfileApiError(
"Workspace public identity bundle does not match identity metadata.",
502,
);
}
if (
(state === "active" &&
(publicBundle === undefined || identity.public_key === undefined ||
identity.public_key_fingerprint === undefined ||
identity.provisioned_at === undefined)) ||
(state === "pending_provisioning" &&
(publicBundle !== undefined || identity.public_key !== undefined ||
identity.public_key_fingerprint !== undefined ||
identity.provisioned_at !== undefined))
) {
throw new ProfileApiError(
"Workspace signing identity returned an invalid response.",
502,
);
}
return {
identity,
...(publicBundle === undefined ? {} : { public_bundle: publicBundle }),
};
}
async function parseResponse<T>(
response: Response,
parser: (value: unknown) => T,
@@ -303,7 +519,7 @@ export async function fetchWorkspaceMetadata(
workspaceId: string,
): Promise<WorkspaceMetadataSettingsResponse> {
return await parseResponse(
await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`),
await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings`),
parseWorkspaceMetadataSettingsResponse,
);
}
@@ -314,7 +530,7 @@ export async function updateWorkspaceMetadata(
): Promise<WorkspaceMetadataMutationResponse> {
return await parseResponse(
await fetch(
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`,
`/api/w/${encodeURIComponent(workspaceId)}/settings`,
{
method: "PUT",
headers: { "content-type": "application/json" },
@@ -325,6 +541,31 @@ export async function updateWorkspaceMetadata(
);
}
export async function fetchWorkspaceSigningIdentity(
workspaceId: string,
): Promise<WorkspaceSigningIdentityResponse> {
return await parseResponse(
await fetch(
`/api/w/${encodeURIComponent(workspaceId)}/settings/signing-identity`,
),
parseWorkspaceSigningIdentityResponse,
);
}
export async function provisionWorkspaceSigningIdentity(
workspaceId: string,
): Promise<WorkspaceSigningIdentityResponse> {
return await parseResponse(
await fetch(
`/api/w/${
encodeURIComponent(workspaceId)
}/settings/signing-identity/provision`,
{ method: "POST" },
),
parseWorkspaceSigningIdentityResponse,
);
}
export async function fetchProfileSettings(
workspaceId: string,
): Promise<ProfileSettingsResponse> {
@@ -20,10 +20,10 @@ function workdir(
repository_key: "repository-1",
current_selector,
current_ref,
materializer_kind: "local_git_worktree",
materializer_kind: "runtime_git_clone",
status: "active",
cleanup_target: {
kind: "local_git_worktree",
kind: "runtime_git_clone",
working_directory_id: "workdir-1",
repository_key: "repository-1",
},
@@ -1,6 +1,7 @@
<script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http';
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
import type { SettingsSectionId } from '$lib/workspace/settings/model';
import type { SidebarSnippet } from './context';
let {
@@ -17,8 +18,9 @@
return workspaceId ? workspaceRoute(workspaceId, path) : path;
}
function isActive(href: string): boolean {
return currentPath === href || currentPath.startsWith(`${href}/`);
function isActive(href: string, sectionId: SettingsSectionId): boolean {
return currentPath === href ||
(sectionId !== 'workspace-identity' && currentPath.startsWith(`${href}/`));
}
</script>
@@ -32,10 +34,10 @@
{#each SETTINGS_SECTIONS as section}
{@const href = sectionHref(settingsSectionHref(section.id))}
<a
class:active={isActive(href)}
class:active={isActive(href, section.id)}
class="sidebar-link"
href={href}
aria-current={isActive(href) ? 'page' : undefined}
aria-current={isActive(href, section.id) ? 'page' : undefined}
>
<span class="sidebar-link-label">{section.label}</span>
</a>
@@ -54,12 +54,12 @@ const options: WorkerLaunchOptionsResponse = {
creation_ref: "0123456789abcdef",
current_selector: null,
current_ref: "0123456789abcdef",
materializer_kind: "local_git_worktree",
materializer_kind: "runtime_git_clone",
status: "active",
cleanliness: "clean",
primary_worker_id: null,
cleanup_target: {
kind: "git_worktree",
kind: "runtime_git_clone",
working_directory_id: "wd-1-repo",
repository_key: "repo",
},
@@ -5,7 +5,12 @@ export function liveWorkerState(worker: {
worker_state?: WorkerStateSnapshot | null;
}): string {
const state = worker.worker_state?.state;
if (!state) return worker.state === "stopped" ? "stopped" : "unknown";
if (!state) {
if (worker.state === "missing" || worker.state === "stopped") {
return worker.state;
}
return "unknown";
}
if (state.kind === "idle") return "idle";
if (state.state.kind === "maintenance") return "running";
return state.state.state === "paused" ? "paused" : "running";
@@ -46,6 +46,10 @@ Deno.test('Worker list state uses the authoritative live snapshot separately fro
const unavailable = worker('runtime-a', 'worker-2', 1);
assertEquals(liveWorkerState(unavailable), 'unknown');
assertEquals(
liveWorkerState({ ...unavailable, state: 'missing' }),
'missing',
);
unavailable.state = 'stopped';
assertEquals(liveWorkerState(unavailable), 'stopped');
});
@@ -1,8 +1,88 @@
<script lang="ts">
import { formatDate } from '$lib/workspace/api/http';
import type {
ConfirmRepositorySshHostTrustRequest,
RepositorySshConnectionProbeRequest,
RepositorySshConnectionProbeResponse
} from '$lib/generated/workspace-api';
import { parseRepositorySshHostTrust } from '$lib/workspace/api/repository-access';
import { formatDate, workspaceApiPath } from '$lib/workspace/api/http';
import { parseRepositorySshConnectionProbeResponse } from '$lib/workspace/api/workspace-model';
import { repositorySshProbeRuntimes } from '$lib/workspace/repositories/ssh-connection';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let selectedRuntimeId = $state('');
let probe = $state<RepositorySshConnectionProbeResponse | null>(null);
let selectedHostKey = $state('');
let pending = $state(false);
let connectionMessage = $state<string | null>(null);
const probeRuntimes = $derived(data.runtimes ? repositorySshProbeRuntimes(data.runtimes.items) : []);
$effect(() => {
if (!selectedRuntimeId) {
selectedRuntimeId = probeRuntimes[0]?.runtime_id ?? '';
}
});
async function requestConnectionTest(method: 'POST' | 'PUT', body: unknown): Promise<unknown> {
const response = await fetch(
workspaceApiPath(
data.repository?.workspace_id ?? '',
`/repositories/${encodeURIComponent(data.repositoryKey)}/ssh-connection-test`
),
{
method,
headers: { accept: 'application/json', 'content-type': 'application/json' },
body: JSON.stringify(body)
}
);
const value = await response.json();
if (!response.ok) {
const record = value && typeof value === 'object' ? value as Record<string, unknown> : null;
throw new Error(typeof record?.message === 'string' ? record.message : `SSH connection test failed with status ${response.status}`);
}
return value;
}
async function runConnectionTest() {
pending = true;
connectionMessage = null;
probe = null;
selectedHostKey = '';
try {
const body: RepositorySshConnectionProbeRequest = { runtime_id: selectedRuntimeId };
probe = parseRepositorySshConnectionProbeResponse(await requestConnectionTest('POST', body));
selectedHostKey = probe.candidates[0]?.host_key ?? '';
connectionMessage = probe.trust_state === 'verified'
? 'The observed SSH host key matches the Workspace trust record.'
: 'Review the observed fingerprint before trusting this SSH host.';
} catch (error) {
connectionMessage = error instanceof Error ? error.message : 'SSH connection test failed';
} finally {
pending = false;
}
}
async function confirmHostTrust() {
if (!probe || !selectedHostKey) return;
pending = true;
connectionMessage = null;
try {
const body: ConfirmRepositorySshHostTrustRequest = {
operation_id: `repository-ssh-confirm-${crypto.randomUUID()}`,
runtime_id: probe.runtime_id,
host_key: selectedHostKey,
expected_host_trust_revision: probe.expected_host_trust_revision
};
parseRepositorySshHostTrust(await requestConnectionTest('PUT', body));
probe = { ...probe, trust_state: 'verified' };
connectionMessage = 'SSH host trust saved. Future connections must present this key.';
} catch (error) {
connectionMessage = error instanceof Error ? error.message : 'Failed to save SSH host trust';
} finally {
pending = false;
}
}
</script>
<svelte:head>
@@ -78,6 +158,47 @@
{/if}
</section>
{#if data.repository?.item.source.kind === 'ssh'}
<section class="card repository-detail-card">
<h2>SSH connection test</h2>
<p>Observe the SSH host key from the same Runtime that will clone this Repository. Nothing is trusted until you confirm a fingerprint below.</p>
{#if data.runtimesError}
<p class="section-state error">{data.runtimesError}</p>
{:else if data.runtimes}
<label>
<span>Runtime</span>
<select bind:value={selectedRuntimeId} disabled={pending}>
{#each probeRuntimes as runtime}
<option value={runtime.runtime_id}>{runtime.label} · {runtime.runtime_id}</option>
{/each}
</select>
</label>
{#if probeRuntimes.length === 0}
<p class="section-state error">No configured remote Runtime is available for this connection test.</p>
{/if}
<button type="button" disabled={pending || !selectedRuntimeId} onclick={() => void runConnectionTest()}>
{pending ? 'Checking…' : 'Check SSH connection'}
</button>
{/if}
{#if probe}
<p><strong>{probe.hostname}:{probe.port}</strong> · {probe.trust_state}</p>
{#each probe.candidates as candidate}
<label class="repository-host-key-candidate">
<input type="radio" name="repository-host-key" bind:group={selectedHostKey} value={candidate.host_key} />
<span><code>{candidate.algorithm}</code> <code>{candidate.fingerprint}</code></span>
</label>
{/each}
{#if probe.trust_state !== 'verified'}
<button type="button" class="danger" disabled={pending || !selectedHostKey} onclick={() => void confirmHostTrust()}>
Confirm and trust selected host key
</button>
{/if}
{/if}
{#if connectionMessage}<p class="section-state" class:error={probe === null}>{connectionMessage}</p>{/if}
</section>
{/if}
<section class="card repository-log-card">
<h2>Recent commits</h2>
{#if data.repositoryLog}
@@ -1,4 +1,5 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
import {
parseRepositoryDetailResponse,
parseRepositoryLogResponse,
@@ -8,7 +9,7 @@ import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId;
const repositoryKey = params.repositoryKey;
const [repositoryResult, logResult] = await Promise.all([
const [repositoryResult, logResult, runtimesResult] = await Promise.all([
loadJson<unknown>(
fetch,
workspaceApiPath(
@@ -23,6 +24,10 @@ export const load: PageLoad = async ({ fetch, params }) => {
`/repositories/${encodeURIComponent(repositoryKey)}/log`,
),
),
loadJson<unknown>(
fetch,
workspaceApiPath(workspaceId, "/runtimes"),
),
]);
let repository = null;
@@ -49,11 +54,25 @@ export const load: PageLoad = async ({ fetch, params }) => {
}
}
let runtimes = null;
let runtimesError = runtimesResult.error;
if (runtimesResult.data !== null) {
try {
runtimes = parseWorkspaceRuntimeList(runtimesResult.data);
} catch (cause) {
runtimesError = cause instanceof Error
? cause.message
: "invalid Runtime summary response";
}
}
return {
repositoryKey,
repository,
repositoryError,
repositoryLog: log,
repositoryLogError: logError,
runtimes,
runtimesError,
};
};
@@ -0,0 +1,395 @@
<script lang="ts">
import type {
Diagnostic,
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspaceDeletionRequest,
WorkspaceMetadataSettingsResponse,
WorkspaceSigningIdentityResponse,
} from '$lib/generated/workspace-api';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
import {
getWorkspaceDeletion,
preflightWorkspaceDeletion,
startWorkspaceDeletion,
} from '$lib/workspace/settings/workspace-deletion-api';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import {
fetchWorkspaceMetadata,
fetchWorkspaceSigningIdentity,
provisionWorkspaceSigningIdentity,
updateWorkspaceMetadata,
} from '$lib/workspace/settings/profile-api';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
let signingIdentity = $state<WorkspaceSigningIdentityResponse | null>(null);
let identityLoading = $state(true);
let identityError = $state<string | null>(null);
let provisioningIdentity = $state(false);
let identityCopied = $state(false);
let identityBundleText = $derived(
signingIdentity?.public_bundle ? JSON.stringify(signingIdentity.public_bundle, null, 2) : ''
);
let displayNameDraft = $state('');
let loading = $state(true);
let submitting = $state(false);
let message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]);
let deletionOpen = $state(false);
let deletionLoading = $state(false);
let deletionSubmitting = $state(false);
let deletionConfirmation = $state('');
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
let deletionError = $state<string | null>(null);
function deletionStorageKey(): string {
return `yoi:workspace-deletion:${workspaceId}`;
}
$effect(() => {
if (!workspaceId) {
loading = false;
return;
}
let cancelled = false;
async function load() {
loading = true;
message = null;
try {
const response = await fetchWorkspaceMetadata(workspaceId);
if (!cancelled) {
workspaceMetadata = response;
displayNameDraft = response.display_name;
diagnostics = response.diagnostics;
if (data.workspace?.permissions.delete_workspace) {
try {
signingIdentity = await fetchWorkspaceSigningIdentity(workspaceId);
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity request failed';
} finally {
identityLoading = false;
}
} else {
identityLoading = false;
}
}
} catch (err) {
if (!cancelled) {
message = err instanceof Error ? err.message : 'workspace settings request failed';
}
} finally {
if (!cancelled) loading = false;
}
}
load();
return () => {
cancelled = true;
};
});
async function submitWorkspaceName() {
if (!workspaceMetadata) return;
submitting = true;
message = null;
try {
const response = await updateWorkspaceMetadata(workspaceId, {
display_name: displayNameDraft,
revision: workspaceMetadata.revision
});
workspaceMetadata = response.workspace;
displayNameDraft = response.workspace.display_name;
diagnostics = response.diagnostics.concat(response.workspace.diagnostics);
message = 'Workspace display name updated.';
} catch (err) {
message = err instanceof Error ? err.message : 'workspace update failed';
} finally {
submitting = false;
}
}
async function provisionIdentity() {
provisioningIdentity = true;
identityError = null;
try {
signingIdentity = await provisionWorkspaceSigningIdentity(workspaceId);
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity provisioning failed';
} finally {
provisioningIdentity = false;
}
}
async function copyIdentityBundle() {
const bundle = signingIdentity?.public_bundle;
if (!bundle) return;
identityCopied = false;
try {
await navigator.clipboard.writeText(JSON.stringify(bundle, null, 2));
identityCopied = true;
} catch (err) {
identityError = err instanceof Error ? err.message : 'Workspace identity bundle copy failed';
}
}
async function openDeletionConfirmation() {
deletionOpen = true;
deletionLoading = true;
deletionError = null;
deletionOperation = null;
deletionRequest = null;
sessionStorage.removeItem(deletionStorageKey());
deletionConfirmation = '';
try {
deletionPreflight = await preflightWorkspaceDeletion(workspaceId);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion preflight failed';
} finally {
deletionLoading = false;
}
}
async function trackDeletion(operationId: string) {
let operation = await getWorkspaceDeletion(operationId);
deletionOperation = operation;
while (operation.state === 'queued' || operation.state === 'running') {
await new Promise((resolve) => setTimeout(resolve, 500));
operation = await getWorkspaceDeletion(operation.operation_id);
deletionOperation = operation;
}
if (operation.state === 'succeeded') {
sessionStorage.removeItem(deletionStorageKey());
disposeWorkspaceMultiplexer(workspaceId);
disposeWorkspaceWorkersStore(workspaceId);
await goto('/');
}
}
function storedDeletionRequest(): WorkspaceDeletionRequest | null {
try {
const value: unknown = JSON.parse(sessionStorage.getItem(deletionStorageKey()) ?? 'null');
if (typeof value !== 'object' || value === null) return null;
const record = value as Record<string, unknown>;
if (
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
typeof record.confirmation !== 'string' || record.confirmation !== data.workspace?.display_name || record.confirmation.length > 256
) return null;
return {
operation_id: record.operation_id,
expected_revision: record.expected_revision,
confirmation: record.confirmation,
};
} catch {
return null;
}
}
onMount(() => {
if (!data.workspace?.permissions.delete_workspace) return;
const request = storedDeletionRequest();
if (!request) return;
deletionRequest = request;
deletionConfirmation = request.confirmation;
deletionOpen = true;
deletionSubmitting = true;
void trackDeletion(request.operation_id)
.catch((err) => {
deletionError = err instanceof Error ? err.message : 'Workspace deletion status failed';
})
.finally(() => {
deletionSubmitting = false;
});
});
async function deleteWorkspace() {
if (!deletionPreflight && !deletionRequest) return;
deletionSubmitting = true;
deletionError = null;
try {
const request = deletionRequest ?? {
operation_id: crypto.randomUUID(),
expected_revision: deletionPreflight!.expected_revision,
confirmation: deletionConfirmation,
};
deletionRequest = request;
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
const operation = await startWorkspaceDeletion(workspaceId, request);
deletionOperation = operation;
await trackDeletion(operation.operation_id);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion failed';
} finally {
deletionSubmitting = false;
}
}
</script>
<svelte:head>
<title>Workspace settings · Yoi Workspace</title>
</svelte:head>
<section class="card settings-section" aria-labelledby="workspace-settings-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">editable</p>
<h2 id="workspace-settings-title">Workspace Identity</h2>
</div>
<span class="badge success">Backend scoped</span>
</header>
{#if loading}
<p class="status-message">Loading workspace settings…</p>
{:else}
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void submitWorkspaceName(); }}>
<label>
<span>Display name</span>
<input bind:value={displayNameDraft} autocomplete="off" />
</label>
<p class="settings-note">Workspace id: <code>{workspaceMetadata?.workspace_id ?? workspaceId}</code></p>
<button type="submit" disabled={submitting || !workspaceMetadata}>{submitting ? 'Saving…' : 'Save workspace name'}</button>
</form>
<dl class="settings-identity-list">
<div>
<dt>Source</dt>
<dd>{workspaceMetadata?.source ?? 'unknown'}</dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{workspaceMetadata?.revision ?? 'unknown'}</code></dd>
</div>
</dl>
{/if}
{#if message}
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
{/if}
<DiagnosticsList {diagnostics} />
</section>
{#if data.workspace?.permissions.delete_workspace}
<section class="settings-section" aria-labelledby="workspace-identity-title">
<div class="section-heading">
<div>
<h2 id="workspace-identity-title">Workspace public identity</h2>
<p>Use this public bundle when connecting a Runtime to this Workspace.</p>
</div>
{#if signingIdentity?.public_bundle}
<button type="button" onclick={() => void copyIdentityBundle()}>
{identityCopied ? 'Copied' : 'Copy bundle'}
</button>
{/if}
</div>
{#if identityError}
<p class="status-message error">{identityError}</p>
{/if}
{#if identityLoading}
<p>Loading identity…</p>
{:else if signingIdentity?.identity.state === 'pending_provisioning'}
<p>This existing Workspace needs one explicit signing identity provisioning operation.</p>
<button
type="button"
disabled={provisioningIdentity}
onclick={() => void provisionIdentity()}
>{provisioningIdentity ? 'Provisioning…' : 'Provision identity'}</button>
{:else if signingIdentity?.public_bundle}
<dl class="metadata-list">
<div>
<dt>Key</dt>
<dd><code>{signingIdentity.identity.key_id}</code></dd>
</div>
<div>
<dt>Fingerprint</dt>
<dd><code>{signingIdentity.identity.public_key_fingerprint}</code></dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{signingIdentity.identity.revision}</code></dd>
</div>
</dl>
<label class="identity-bundle">
<span>Public identity bundle</span>
<textarea readonly rows="9" value={identityBundleText}></textarea>
</label>
{/if}
</section>
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
<div>
<h2 id="workspace-danger-title">Danger zone</h2>
<p>Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.</p>
</div>
<button class="danger-button" type="button" onclick={() => void openDeletionConfirmation()}>Delete Workspace</button>
</section>
{/if}
{#if deletionOpen}
<div class="modal-backdrop" role="presentation">
<div class="deletion-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-workspace-title">
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? 'Workspace'}?</h2>
{#if deletionLoading}
<p>Loading deletion impact…</p>
{:else if deletionPreflight}
<p>This operation cannot be undone. It will remove:</p>
<ul>
<li>{deletionPreflight.resources.workers} Workers</li>
<li>{deletionPreflight.resources.workdirs} Workdirs</li>
<li>{deletionPreflight.resources.repositories} repositories</li>
<li>{deletionPreflight.resources.runtime_bindings} Runtime bindings</li>
<li>{deletionPreflight.resources.secrets} secret records</li>
<li>{deletionPreflight.resources.artifacts} artifacts</li>
</ul>
{#each deletionPreflight.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
<label>
<span>Type <strong>{deletionPreflight.display_name}</strong> to confirm</span>
<input bind:value={deletionConfirmation} autocomplete="off" />
</label>
{/if}
{#if deletionOperation}
<p class="status-message">Deletion state: {deletionOperation.state}</p>
{#each deletionOperation.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
{/if}
{#if deletionError}<p class="status-message error">{deletionError}</p>{/if}
<div class="dialog-actions">
<button type="button" onclick={() => { deletionOpen = false; }} disabled={deletionSubmitting}>Cancel</button>
<button
class="danger-button"
type="button"
onclick={() => void deleteWorkspace()}
disabled={deletionSubmitting || (!deletionRequest && !deletionPreflight?.can_delete) || deletionConfirmation !== (deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? '')}
>{deletionSubmitting ? 'Deleting…' : 'Delete Workspace'}</button>
</div>
</div>
</div>
{/if}
<style>
.section-heading { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); }
.section-heading p { margin-block: var(--space-1) 0; }
.metadata-list { display: grid; gap: var(--space-2); }
.metadata-list div { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: var(--space-3); }
.metadata-list dd { margin: 0; overflow-wrap: anywhere; }
.identity-bundle { display: grid; gap: var(--space-2); margin-top: var(--space-4); }
.identity-bundle textarea { width: 100%; resize: vertical; font-family: var(--font-mono); font-size: 0.75rem; }
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
.danger-zone p { max-width: 68ch; }
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: var(--space-4); background: rgb(0 0 0 / 0.55); }
.deletion-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; padding: var(--space-5); background: var(--color-surface, white); border: 1px solid var(--color-border); }
.deletion-dialog label { display: grid; gap: var(--space-2); margin-block: var(--space-4); }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-5); }
</style>
@@ -4,24 +4,33 @@
CreateRepositorySshCredentialRequest,
DeleteRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest,
GenerateRepositorySshCredentialRequest,
PutRepositorySshHostTrustRequest,
RepositorySshCredential,
RepositorySshHostTrust,
RepositorySshPublicKey,
RotateRepositorySshCredentialRequest,
} from '$lib/generated/repository-access-api';
import {
parseRepositorySshCredential,
parseRepositorySshHostTrust,
parseRepositorySshPublicKey,
} from '$lib/workspace/api/repository-access';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials));
let publicKeys = $state<Record<string, RepositorySshPublicKey>>(
Object.fromEntries(untrack(() => data.publicKeys).map((key) => [key.credential_id, key]))
);
let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
const accessProjection = untrack(() => data.accessProjection);
let message = $state<string | null>(null);
let pending = $state(false);
let copiedCredentialId = $state<string | null>(null);
let generateCredentialId = $state('');
let generateCredentialName = $state('');
let credentialId = $state('');
let credentialName = $state('');
let privateKey = $state('');
@@ -37,6 +46,7 @@
let hostExpectedRevision = $state<number | null>(null);
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
const workspaceDefaultCredentialId = 'workspace-default';
function operationId(prefix: string): string {
return `${prefix}-${crypto.randomUUID()}`;
@@ -71,6 +81,55 @@
return parse(payload);
}
async function loadPublicKey(credentialId: string): Promise<RepositorySshPublicKey> {
const response = await fetch(
`${base}/credentials/${encodeURIComponent(credentialId)}/public-key`,
{ headers: { accept: 'application/json' } }
);
const body = await response.json();
if (!response.ok) {
throw new Error(`Repository Access request failed with status ${response.status}.`);
}
return parseRepositorySshPublicKey(body);
}
async function generateCredential() {
pending = true;
message = null;
try {
const body: GenerateRepositorySshCredentialRequest = {
operation_id: operationId('credential-generate'),
credential_id: generateCredentialId,
name: generateCredentialName
};
const created = await request('/credentials/generate', 'POST', body, parseRepositorySshCredential);
const publicKey = await loadPublicKey(created.credential_id);
credentials = [...credentials.filter((item) => item.credential_id !== created.credential_id), created];
publicKeys = { ...publicKeys, [created.credential_id]: publicKey };
generateCredentialId = '';
generateCredentialName = '';
message = `Generated SSH credential ${created.credential_id}`;
} catch (error) {
message = error instanceof Error ? error.message : 'Failed to generate SSH credential';
} finally {
pending = false;
}
}
async function copyPublicKey(credentialId: string) {
const publicKey = publicKeys[credentialId]?.public_key;
if (!publicKey) return;
try {
await navigator.clipboard.writeText(publicKey);
copiedCredentialId = credentialId;
window.setTimeout(() => {
if (copiedCredentialId === credentialId) copiedCredentialId = null;
}, 1500);
} catch {
message = 'Failed to copy the public key';
}
}
async function createCredential() {
pending = true;
message = null;
@@ -88,7 +147,9 @@
body,
parseRepositorySshCredential
);
const publicKey = await loadPublicKey(created.credential_id);
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
publicKeys = { ...publicKeys, [created.credential_id]: publicKey };
credentialId = '';
credentialName = '';
message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`;
@@ -117,7 +178,9 @@
body,
parseRepositorySshCredential
);
const publicKey = await loadPublicKey(rotated.credential_id);
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
publicKeys = { ...publicKeys, [rotated.credential_id]: publicKey };
rotateCredentialId = null;
message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`;
} catch (error) {
@@ -145,6 +208,9 @@
null
);
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id);
const remainingPublicKeys = { ...publicKeys };
delete remainingPublicKeys[credential.credential_id];
publicKeys = remainingPublicKeys;
message = `Credential ${credential.credential_id} deleted.`;
} catch (error) {
message = error instanceof Error ? error.message : 'Credential deletion failed';
@@ -227,7 +293,7 @@
<div><p class="eyebrow">owner only</p><h2>Repository Access</h2></div>
<span class="badge success">encrypted</span>
</header>
<p>Manage Workspace-scoped SSH credentials and pinned host keys. Private keys and passphrases are write-only and never returned by this page.</p>
<p>The Workspace default SSH key is generated separately from the Runtime authentication identity and is always offered during SSH clone. Without an explicit Repository binding, a unique pinned host trust matching the Repository URI is used with this default key. A binding can add one dedicated credential; OpenSSH receives both candidates and tries them through one operation-scoped agent. Private keys and passphrases remain write-only.</p>
{#if message}<p class="status-message">{message}</p>{/if}
<div class="settings-runtime-list">
@@ -237,7 +303,7 @@
{#each accessProjection.bindings as binding (binding.repository_key)}
<div class="card">
<strong>{binding.repository_key}</strong>
<p>{binding.access} · credential <code>{binding.credential_id}</code> · host trust <code>{binding.host_trust_id}</code></p>
<p>{binding.access} · additional credential <code>{binding.credential_id}</code> · always includes <code>{workspaceDefaultCredentialId}</code> · host trust <code>{binding.host_trust_id}</code></p>
</div>
{/each}
</div>
@@ -248,12 +314,19 @@
{#each credentials as credential (credential.credential_id)}
<div class="card">
<strong>{credential.name}</strong> <code>{credential.credential_id}</code>
{#if credential.credential_id === workspaceDefaultCredentialId}<span class="badge success">Workspace default</span>{/if}
<p>{credential.public_key_algorithm} · {credential.public_key_fingerprint} · revision {credential.current_revision}</p>
<p>References: {credential.referenced_repositories.join(', ') || 'none'}</p>
{#if publicKeys[credential.credential_id]}
<label><span>Public key</span><textarea readonly rows="3" value={publicKeys[credential.credential_id].public_key}></textarea></label>
<button type="button" onclick={() => void copyPublicKey(credential.credential_id)}>{copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'}</button>
{/if}
<p>References: {credential.credential_id === workspaceDefaultCredentialId ? 'all SSH repository operations' : credential.referenced_repositories.join(', ') || 'none'}</p>
{#if credential.credential_id !== workspaceDefaultCredentialId}
<div class="settings-action-row">
<button type="button" onclick={() => (rotateCredentialId = rotateCredentialId === credential.credential_id ? null : credential.credential_id)}>Rotate</button>
<button type="button" class="danger" disabled={pending || credential.referenced_repositories.length > 0} onclick={() => void deleteCredential(credential)}>Delete</button>
</div>
{/if}
{#if rotateCredentialId === credential.credential_id}
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void rotateCredential(credential); }}>
<label><span>New private key</span><textarea bind:value={rotatePrivateKey} required rows="8" autocomplete="off"></textarea></label>
@@ -264,8 +337,16 @@
</div>
{/each}
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void generateCredential(); }}>
<h3>Generate Repository SSH credential</h3>
<p>Create an additional Ed25519 key for a Repository binding. The Workspace default SSH key is already generated automatically and is included separately.</p>
<label><span>Credential id</span><input bind:value={generateCredentialId} placeholder="repository-deploy" required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
<label><span>Name</span><input bind:value={generateCredentialName} placeholder="Repository deploy key" required maxlength="200" /></label>
<button type="submit" disabled={pending}>Generate credential</button>
</form>
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void createCredential(); }}>
<h3>Add SSH credential</h3>
<h3>Import existing SSH credential</h3>
<label><span>Credential id</span><input bind:value={credentialId} required pattern="[A-Za-z0-9_.-]+" maxlength="128" /></label>
<label><span>Name</span><input bind:value={credentialName} required maxlength="200" /></label>
<label><span>OpenSSH private key (ssh-ed25519)</span><textarea bind:value={privateKey} required rows="10" autocomplete="off"></textarea></label>
@@ -3,6 +3,7 @@ import {
parseRepositoryAccessProjection,
parseRepositorySshCredentials,
parseRepositorySshHostTrusts,
parseRepositorySshPublicKey,
} from "$lib/workspace/api/repository-access";
import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader";
import type { PageLoad } from "./$types";
@@ -27,9 +28,25 @@ export const load: PageLoad = async ({ fetch, params }) => {
),
]);
const publicKeys = await Promise.all(
credentials.map((credential) =>
loadRepositoryAccessJson(
fetch,
workspaceApiPath(
workspaceId,
`/settings/repository-access/credentials/${
encodeURIComponent(credential.credential_id)
}/public-key`,
),
parseRepositorySshPublicKey,
)
),
);
return {
workspaceId,
credentials,
publicKeys,
hostTrusts,
accessProjection,
};
@@ -2,20 +2,32 @@
import { invalidateAll } from '$app/navigation';
import type {
RuntimeConnectionTestResponse,
RuntimePublicIdentityBundle,
WorkspaceRuntimeResource,
} from '$lib/generated/workspace-api';
import {
createRemoteRuntime,
previewRuntimePublicKeyFingerprint,
RuntimeTrustRequestError,
} from '$lib/workspace/api/runtime-management';
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
import { workspaceApiPath } from '$lib/workspace/api/http';
import { provisionWorkspaceSigningIdentity } from '$lib/workspace/settings/profile-api';
import type { PageProps } from './$types';
const runtimeBundlePlaceholder =
'{"identity_id":"team-runtime","public_key":"yoi-ed25519-pub:v1:..."}';
let { data }: PageProps = $props();
let runtimeId = $state('');
let runtimePublicBundle = $state('');
let displayName = $state('');
let endpoint = $state('');
let runtimeFingerprint = $state<string | null>(null);
let showAddRuntime = $state(false);
let busyRuntimeId = $state<string | null>(null);
let requestError = $state<string | null>(null);
let requestNotice = $state<string | null>(null);
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
let connectionTestGeneration = 0;
function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
@@ -23,7 +35,9 @@
function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
if (result.status === 'compatible') {
return `Compatible · protocol v${result.actual_protocol_version}`;
return result.connection_state === 'verified'
? `Verified · protocol v${result.actual_protocol_version}`
: `Compatible · ${result.connection_state} · protocol v${result.actual_protocol_version}`;
}
switch (result.failure_kind) {
case 'authentication': return 'Authentication failed';
@@ -46,52 +60,146 @@
return 'Observed';
}
async function responseError(response: Response): Promise<string> {
const payload = await response.json().catch(() => null) as
| { message?: string; error?: string }
| null;
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`;
function parseRuntimePublicBundle(value: string): RuntimePublicIdentityBundle {
let parsed: unknown;
try {
parsed = JSON.parse(value);
} catch {
throw new Error('Runtime public bundle must be valid JSON');
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Runtime public bundle must be a JSON object');
}
const item = parsed as Record<string, unknown>;
if (
Object.keys(item).length !== 2 ||
typeof item.identity_id !== 'string' ||
item.identity_id.length === 0 ||
typeof item.public_key !== 'string' ||
item.public_key.length === 0
) {
throw new Error('Runtime public bundle must contain only identity_id and public_key');
}
return { identity_id: item.identity_id, public_key: item.public_key };
}
function workspacePublicBundle(): string {
return data.signingIdentity?.public_bundle
? JSON.stringify(data.signingIdentity.public_bundle, null, 2)
: '';
}
function workspaceBundleFilename(): string {
return `workspace-${data.workspaceId}-public-bundle.json`;
}
async function provisionSigningIdentity(): Promise<void> {
requestError = null;
requestNotice = null;
busyRuntimeId = 'provision-workspace-identity';
try {
await provisionWorkspaceSigningIdentity(data.workspaceId);
await invalidateAll();
requestNotice = 'Workspace identity provisioned. Copy its public bundle to the Runtime host.';
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
}
async function copyWorkspaceBundle(): Promise<void> {
requestError = null;
try {
await navigator.clipboard.writeText(workspacePublicBundle());
} catch {
requestError = 'Workspace public bundle could not be copied';
}
}
async function previewRuntimeFingerprint(): Promise<void> {
requestError = null;
runtimeFingerprint = null;
busyRuntimeId = 'preview';
try {
const bundle = parseRuntimePublicBundle(runtimePublicBundle);
runtimeFingerprint = await previewRuntimePublicKeyFingerprint(bundle.public_key);
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
} finally {
busyRuntimeId = null;
}
}
async function addRuntime(event: SubmitEvent): Promise<void> {
event.preventDefault();
requestError = null;
requestNotice = null;
busyRuntimeId = 'create';
try {
const response = await fetch(workspaceApiPath(data.workspaceId, '/runtimes'), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
runtime_id: runtimeId,
const publicBundle = parseRuntimePublicBundle(runtimePublicBundle);
const currentFingerprint = await previewRuntimePublicKeyFingerprint(publicBundle.public_key);
if (runtimeFingerprint !== currentFingerprint) {
throw new Error('Preview the Runtime public key fingerprint before registration');
}
await createRemoteRuntime(data.workspaceId, {
public_bundle: publicBundle,
display_name: displayName || null,
endpoint,
}),
expected_revision: null,
});
if (!response.ok) throw new Error(await responseError(response));
runtimeId = '';
runtimePublicBundle = '';
runtimeFingerprint = null;
displayName = '';
endpoint = '';
showAddRuntime = false;
requestNotice = 'Runtime registered for this Workspace. Run Test to complete authenticated verification.';
await invalidateAll();
} catch (error) {
requestError = error instanceof Error ? error.message : String(error);
requestError = error instanceof RuntimeTrustRequestError || error instanceof Error
? error.message
: String(error);
} finally {
busyRuntimeId = null;
}
}
function currentTestResult(
runtime: WorkspaceRuntimeResource,
): RuntimeConnectionTestResponse | undefined {
const result = testResults[runtime.runtime_id];
return result?.binding_revision === runtime.management?.binding?.revision
? result
: undefined;
}
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> {
const bindingRevision = runtime.management?.binding?.revision;
if (typeof bindingRevision !== 'number') return;
const generation = ++connectionTestGeneration;
requestError = null;
busyRuntimeId = runtime.runtime_id;
try {
const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id);
if (
generation !== connectionTestGeneration ||
result.binding_revision !== bindingRevision
) {
await invalidateAll();
return;
}
testResults = { ...testResults, [runtime.runtime_id]: result };
await invalidateAll();
} catch (error) {
if (generation === connectionTestGeneration) {
requestError = error instanceof Error ? error.message : String(error);
}
} finally {
if (generation === connectionTestGeneration) {
busyRuntimeId = null;
}
}
}
</script>
<svelte:head>
@@ -114,12 +222,87 @@
{#if showAddRuntime && data.workspace.permissions.manage_runtimes}
<form class="settings-runtime-form" onsubmit={addRuntime}>
<h2>Add remote Runtime</h2>
<header>
<h2>Connect a remote Runtime</h2>
<p>
This creates a binding for this Workspace. The Runtime can remain connected to other Workspaces;
their trust entries are not replaced.
</p>
</header>
<section class="settings-runtime-trust-instructions" aria-labelledby="workspace-to-runtime-heading">
<h3 id="workspace-to-runtime-heading">1. Trust this Workspace on the Runtime</h3>
<p>
Each Workspace has its own signing identity. Add this Workspace public bundle to the same store used
when starting the Runtime.
</p>
{#if data.signingIdentityError}
<p class="section-state error">{data.signingIdentityError}</p>
{:else if data.signingIdentity?.identity.state === 'pending_provisioning'}
<p>This Workspace does not have an active signing identity yet.</p>
<button
type="button"
disabled={busyRuntimeId !== null}
onclick={() => void provisionSigningIdentity()}
>
{busyRuntimeId === 'provision-workspace-identity' ? 'Provisioning…' : 'Provision Workspace identity'}
</button>
{:else if data.signingIdentity?.public_bundle}
<p>
Save the bundle as <code>{workspaceBundleFilename()}</code> on the Runtime host. It contains no
private key material.
</p>
<pre>{workspacePublicBundle()}</pre>
<button type="button" disabled={busyRuntimeId !== null} onclick={copyWorkspaceBundle}>
Copy Workspace public bundle
</button>
<pre>yoi-runtime trust-workspace add --bundle {workspaceBundleFilename()}</pre>
<small>
Pass the same <code>--fs-root</code> and <code>--fs-runtime-dir</code> options used by the Runtime
service. Existing Workspace trust entries are preserved.
</small>
{:else}
<p class="section-state">Loading Workspace public identity…</p>
{/if}
</section>
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-to-workspace-heading">
<h3 id="runtime-to-workspace-heading">2. Verify the Runtime identity</h3>
<p>
On the Runtime host, run <code>yoi-runtime identity show --json</code> with the same Runtime storage
options, then paste the public bundle below.
</p>
<div class="settings-form-grid">
<label>
Runtime ID
<input bind:value={runtimeId} required autocomplete="off" />
<label class="settings-form-wide">
Runtime public bundle
<textarea
bind:value={runtimePublicBundle}
oninput={() => {
runtimeFingerprint = null;
}}
required
rows="5"
spellcheck="false"
placeholder={runtimeBundlePlaceholder}
></textarea>
<button type="button" disabled={busyRuntimeId !== null} onclick={previewRuntimeFingerprint}>
Preview fingerprint
</button>
</label>
{#if runtimeFingerprint}
<dl class="runtime-facts">
<div>
<dt>Runtime fingerprint</dt>
<dd><code>{runtimeFingerprint}</code></dd>
</div>
</dl>
{/if}
</div>
</section>
<section class="settings-runtime-trust-instructions" aria-labelledby="runtime-connection-heading">
<h3 id="runtime-connection-heading">3. Register the connection</h3>
<div class="settings-form-grid">
<label>
Display name
<input bind:value={displayName} autocomplete="off" />
@@ -129,8 +312,17 @@
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" />
</label>
</div>
<p>
Registration stores this Workspace-scoped binding. After it appears in the list, run
<strong>Test</strong> to complete authenticated verification.
</p>
</section>
<div class="settings-action-row">
<button type="submit" disabled={busyRuntimeId !== null}>Add Runtime</button>
<button
type="submit"
disabled={busyRuntimeId !== null || !data.signingIdentity?.public_bundle || !runtimeFingerprint}
>Register Runtime</button>
<button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
Cancel
</button>
@@ -141,6 +333,9 @@
{#if requestError}
<p class="section-state error">{requestError}</p>
{/if}
{#if requestNotice}
<p class="section-state">{requestNotice}</p>
{/if}
{#if data.runtimesError}
<p class="section-state error">{data.runtimesError}</p>
@@ -174,7 +369,9 @@
<small><code>{runtime.runtime_id}</code></small>
</td>
<td>{runtime.kind}</td>
<td>{runtime.status}</td>
<td>
{runtime.management?.binding?.connection_state ?? runtime.status}
</td>
<td>{runtimePlatform(runtime)}</td>
<td>{managementLabel(runtime)}</td>
<td>
@@ -184,20 +381,23 @@
</td>
<td>
<div class="settings-action-row">
{#if runtime.management?.config_managed}
{#if runtime.management?.config_managed && runtime.management.binding?.connection_state !== 'revoked'}
<button
type="button"
disabled={busyRuntimeId !== null}
onclick={() => testRuntime(runtime)}
>Test</button>
{/if}
{#if !runtime.management?.config_managed}
{#if runtime.management?.binding?.state === 'configured'}
<span class="settings-muted-action">Verification required</span>
{:else if !runtime.management?.config_managed}
<span class="settings-muted-action">Test unavailable</span>
{/if}
</div>
</td>
</tr>
{#if runtime.diagnostics.length > 0 || testResults[runtime.runtime_id]}
{@const currentResult = currentTestResult(runtime)}
{#if runtime.diagnostics.length > 0 || currentResult}
<tr class="settings-runtime-detail-row">
<td colspan="7">
{#if runtime.diagnostics.length > 0}
@@ -210,14 +410,13 @@
{/each}
</ul>
{/if}
{#if testResults[runtime.runtime_id]}
{@const result = testResults[runtime.runtime_id]}
<div class:failed={result.status === 'failed'} class="settings-test-result">
<strong>Connection test: {connectionTestSummary(result)}</strong>
{#if result.diagnostics[0]}
<span>{result.diagnostics[0].message}</span>
{#if currentResult}
<div class:failed={currentResult.status === 'failed'} class="settings-test-result">
<strong>Connection test: {connectionTestSummary(currentResult)}</strong>
{#if currentResult.diagnostics[0]}
<span>{currentResult.diagnostics[0].message}</span>
{/if}
<small>Checked {new Date(result.checked_at).toLocaleString()}</small>
<small>Checked {new Date(currentResult.checked_at).toLocaleString()}</small>
</div>
{/if}
</td>
@@ -1,5 +1,6 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
import { parseWorkspaceSigningIdentityResponse } from "$lib/workspace/settings/profile-api";
import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => {
@@ -16,9 +17,24 @@ export const load: PageLoad = async ({ fetch, params }) => {
},
);
const signingIdentity = await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/settings/signing-identity"),
undefined,
(value) => {
const response = parseWorkspaceSigningIdentityResponse(value);
if (response.identity.workspace_id !== params.workspaceId) {
throw new Error("Workspace signing identity did not match the route");
}
return response;
},
);
return {
workspaceId: params.workspaceId,
runtimes: runtimes.data,
runtimesError: runtimes.error,
signingIdentity: signingIdentity.data,
signingIdentityError: signingIdentity.error,
};
};
@@ -1,13 +1,13 @@
<script lang="ts">
import { invalidateAll } from '$app/navigation';
import { goto, invalidateAll } from '$app/navigation';
import type {
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyStatus,
} from '$lib/generated/workspace-api';
import {
createRemoteRuntime,
deleteRemoteRuntime,
previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey,
revealRuntimeTrustKey,
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
@@ -23,10 +23,10 @@
let showPublicKey = $state(false);
let revealedPublicKey = $state<string | null>(null);
let publicKey = $state('');
let fingerprintConfirmation = $state('');
let revokeFingerprintConfirmation = $state('');
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | null>(null);
let deleteRuntimeConfirmation = $state('');
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
let fieldError = $state<string | null>(null);
let deleteRuntimeError = $state<string | null>(null);
let requestError = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let replacementFingerprint = $state<string | null>(null);
@@ -43,10 +43,10 @@
showPublicKey = false;
revealedPublicKey = null;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
deleteRuntimeConfirmation = '';
busyAction = null;
fieldError = null;
deleteRuntimeError = null;
requestError = null;
successMessage = null;
replacementFingerprint = null;
@@ -133,30 +133,27 @@
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,
};
const operation = routeFence.capture(data.runtimeId);
busyAction = 'save';
try {
await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request);
const binding = data.runtimeDetail.runtime.management.binding;
if (!binding || !data.runtimeDetail.endpoint) {
throw new RuntimeTrustRequestError(
'The Workspace identity binding and authoritative Runtime endpoint are required.',
);
}
await createRemoteRuntime(data.workspaceId, {
public_bundle: {
identity_id: operation.runtimeId,
public_key: key,
},
display_name: data.runtimeDetail.runtime.label,
endpoint: data.runtimeDetail.endpoint,
expected_revision: binding.revision,
});
if (!isCurrentRoute(operation)) return;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = action === 'create'
@@ -167,7 +164,6 @@
await reloadAuthority();
} catch (error) {
if (!isCurrentRoute(operation)) return;
fingerprintConfirmation = '';
if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority();
@@ -188,11 +184,8 @@
requestError = 'Only active Workspace trust can be revoked.';
return;
}
if (
!trust.fingerprint ||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
) {
fieldError = 'Enter the current fingerprint exactly before revoking Workspace trust.';
if (!trust.fingerprint) {
requestError = 'The authoritative fingerprint is unavailable. Reload before revoking trust.';
return;
}
@@ -211,12 +204,10 @@
operation.runtimeId,
request,
trust.fingerprint,
revokeFingerprintConfirmation,
trust.fingerprint,
);
if (!isCurrentRoute(operation)) return;
publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
showPublicKey = false;
revealedPublicKey = null;
successMessage = 'Workspace trust was revoked.';
@@ -234,6 +225,48 @@
}
}
async function deleteRegistration(): Promise<void> {
if (busyAction !== null || !data.runtimeDetail) return;
const runtime = data.runtimeDetail.runtime;
if (runtime.management.built_in) return;
if (deleteRuntimeConfirmation.trim() !== data.runtimeId) {
deleteRuntimeError = 'Enter the Runtime ID exactly to confirm deletion.';
return;
}
const operation = routeFence.capture(data.runtimeId);
busyAction = 'delete';
deleteRuntimeError = null;
try {
if (data.runtimeDetail.trust_key.status !== 'revoked') {
const trust = data.runtimeDetail.trust_key;
if (trust.revision == null || !trust.fingerprint) {
throw new Error('Runtime trust revision and fingerprint are required before deletion.');
}
await revokeRuntimeTrustKey(
data.workspaceId,
operation.runtimeId,
{ expected_revision: trust.revision },
trust.fingerprint,
trust.fingerprint,
);
if (!isCurrentRoute(operation)) return;
}
await deleteRemoteRuntime(data.workspaceId, operation.runtimeId);
if (!isCurrentRoute(operation)) return;
await goto(`/w/${encodeURIComponent(data.workspaceId)}/settings/runtimes`, {
replaceState: true,
});
} catch (error) {
if (!isCurrentRoute(operation)) return;
deleteRuntimeError = error instanceof Error
? error.message
: 'Runtime registration deletion failed.';
} finally {
if (isCurrentRoute(operation)) busyAction = null;
}
}
async function togglePublicKeyReveal(): Promise<void> {
if (showPublicKey) {
showPublicKey = false;
@@ -315,7 +348,12 @@
<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>Connection state</dt><dd>{runtime.management.binding?.connection_state ?? 'Not configured'}</dd></div>
<div><dt>Workspace signing key</dt><dd><code>{runtime.management.binding?.workspace_key_id ?? '—'}</code></dd></div>
<div><dt>Verified</dt><dd>{formatTimestamp(runtime.management.binding?.verification?.verified_at)}</dd></div>
<div><dt>Verified binding revision</dt><dd>{runtime.management.binding?.verification?.binding_revision?.toString() ?? '—'}</dd></div>
<div><dt>Last verification check</dt><dd>{runtime.management.binding?.verification?.last_outcome ?? '—'} · {formatTimestamp(runtime.management.binding?.verification?.last_checked_at)}</dd></div>
<div><dt>Runtime key status</dt><dd>{trust.status}</dd></div>
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
@@ -384,18 +422,6 @@
<p class="field-error">{replacementFingerprintError}</p>
{/if}
{#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}
@@ -410,25 +436,11 @@
<div>
<strong>Revoke Workspace trust</strong>
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p>
<label>
Confirm current fingerprint
<input
bind:value={revokeFingerprintConfirmation}
autocomplete="off"
spellcheck="false"
disabled={trust.status !== 'active' || busyAction !== null}
/>
<small>Enter <code>{trust.fingerprint ?? 'the current fingerprint'}</code> exactly before revocation.</small>
</label>
</div>
<button
type="button"
class="danger"
disabled={
busyAction !== null ||
trust.status !== 'active' ||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
}
disabled={busyAction !== null || trust.status !== 'active'}
onclick={revokeTrust}
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
</div>
@@ -467,5 +479,45 @@
</div>
{/if}
</section>
{#if data.workspace.permissions.manage_runtimes && !runtime.management.built_in}
<section class="runtime-detail-section runtime-danger-zone" aria-labelledby="runtime-delete-heading">
<h2 id="runtime-delete-heading">Delete Runtime registration</h2>
<p>
Remove this Runtime binding from the current Workspace. This does not stop the Runtime process,
delete its Workers or Workdirs, or revoke this Workspace on the Runtime host.
</p>
{#if trust.status !== 'revoked'}
<p class="section-state warning">
Deletion will revoke this Workspace trust first. Stop or move active Workers before continuing.
</p>
{/if}
<label for="runtime-delete-confirmation">Confirm Runtime ID</label>
<input
id="runtime-delete-confirmation"
bind:value={deleteRuntimeConfirmation}
autocomplete="off"
spellcheck="false"
disabled={busyAction !== null}
placeholder={data.runtimeId}
/>
<small>Enter <code>{data.runtimeId}</code> exactly.</small>
{#if deleteRuntimeError}
<p class="section-state error" role="alert">{deleteRuntimeError}</p>
{/if}
<div class="settings-action-row">
<button
type="button"
class="danger"
disabled={busyAction !== null || deleteRuntimeConfirmation.trim() !== data.runtimeId}
onclick={deleteRegistration}
>{busyAction === 'delete'
? 'Deleting…'
: trust.status === 'revoked'
? 'Delete registration'
: 'Revoke trust and delete registration'}</button>
</div>
</section>
{/if}
{/if}
</section>
@@ -1,296 +0,0 @@
<script lang="ts">
import type {
Diagnostic,
WorkspaceDeletionOperationResponse,
WorkspaceDeletionPreflightResponse,
WorkspaceDeletionRequest,
WorkspaceMetadataSettingsResponse,
} from '$lib/generated/workspace-api';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { disposeWorkspaceMultiplexer } from '$lib/workspace/multiplexer';
import { disposeWorkspaceWorkersStore } from '$lib/workspace/sidebar/worker-subscription';
import {
getWorkspaceDeletion,
preflightWorkspaceDeletion,
startWorkspaceDeletion,
} from '$lib/workspace/settings/workspace-deletion-api';
import DiagnosticsList from '$lib/workspace/settings/DiagnosticsList.svelte';
import {
fetchWorkspaceMetadata,
updateWorkspaceMetadata,
} from '$lib/workspace/settings/profile-api';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let workspaceId = $derived(data.workspace?.workspace_id ?? '');
let workspaceMetadata = $state<WorkspaceMetadataSettingsResponse | null>(null);
let displayNameDraft = $state('');
let loading = $state(true);
let submitting = $state(false);
let message = $state<string | null>(null);
let diagnostics = $state<Diagnostic[]>([]);
let deletionOpen = $state(false);
let deletionLoading = $state(false);
let deletionSubmitting = $state(false);
let deletionConfirmation = $state('');
let deletionPreflight = $state<WorkspaceDeletionPreflightResponse | null>(null);
let deletionOperation = $state<WorkspaceDeletionOperationResponse | null>(null);
let deletionRequest = $state<WorkspaceDeletionRequest | null>(null);
let deletionError = $state<string | null>(null);
function deletionStorageKey(): string {
return `yoi:workspace-deletion:${workspaceId}`;
}
$effect(() => {
if (!workspaceId) {
loading = false;
return;
}
let cancelled = false;
async function load() {
loading = true;
message = null;
try {
const response = await fetchWorkspaceMetadata(workspaceId);
if (!cancelled) {
workspaceMetadata = response;
displayNameDraft = response.display_name;
diagnostics = response.diagnostics;
}
} catch (err) {
if (!cancelled) {
message = err instanceof Error ? err.message : 'workspace settings request failed';
}
} finally {
if (!cancelled) loading = false;
}
}
load();
return () => {
cancelled = true;
};
});
async function submitWorkspaceName() {
if (!workspaceMetadata) return;
submitting = true;
message = null;
try {
const response = await updateWorkspaceMetadata(workspaceId, {
display_name: displayNameDraft,
revision: workspaceMetadata.revision
});
workspaceMetadata = response.workspace;
displayNameDraft = response.workspace.display_name;
diagnostics = response.diagnostics.concat(response.workspace.diagnostics);
message = 'Workspace display name updated.';
} catch (err) {
message = err instanceof Error ? err.message : 'workspace update failed';
} finally {
submitting = false;
}
}
async function openDeletionConfirmation() {
deletionOpen = true;
deletionLoading = true;
deletionError = null;
deletionOperation = null;
deletionRequest = null;
sessionStorage.removeItem(deletionStorageKey());
deletionConfirmation = '';
try {
deletionPreflight = await preflightWorkspaceDeletion(workspaceId);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion preflight failed';
} finally {
deletionLoading = false;
}
}
async function trackDeletion(operationId: string) {
let operation = await getWorkspaceDeletion(operationId);
deletionOperation = operation;
while (operation.state === 'queued' || operation.state === 'running') {
await new Promise((resolve) => setTimeout(resolve, 500));
operation = await getWorkspaceDeletion(operation.operation_id);
deletionOperation = operation;
}
if (operation.state === 'succeeded') {
sessionStorage.removeItem(deletionStorageKey());
disposeWorkspaceMultiplexer(workspaceId);
disposeWorkspaceWorkersStore(workspaceId);
await goto('/');
}
}
function storedDeletionRequest(): WorkspaceDeletionRequest | null {
try {
const value: unknown = JSON.parse(sessionStorage.getItem(deletionStorageKey()) ?? 'null');
if (typeof value !== 'object' || value === null) return null;
const record = value as Record<string, unknown>;
if (
Object.keys(record).sort().join(',') !== 'confirmation,expected_revision,operation_id' ||
typeof record.operation_id !== 'string' || record.operation_id.length === 0 || record.operation_id.length > 128 ||
!/^[A-Za-z0-9_-]+$/.test(record.operation_id) ||
typeof record.expected_revision !== 'string' || record.expected_revision.length > 128 ||
typeof record.confirmation !== 'string' || record.confirmation !== data.workspace?.display_name || record.confirmation.length > 256
) return null;
return {
operation_id: record.operation_id,
expected_revision: record.expected_revision,
confirmation: record.confirmation,
};
} catch {
return null;
}
}
onMount(() => {
if (!data.workspace?.permissions.delete_workspace) return;
const request = storedDeletionRequest();
if (!request) return;
deletionRequest = request;
deletionConfirmation = request.confirmation;
deletionOpen = true;
deletionSubmitting = true;
void trackDeletion(request.operation_id)
.catch((err) => {
deletionError = err instanceof Error ? err.message : 'Workspace deletion status failed';
})
.finally(() => {
deletionSubmitting = false;
});
});
async function deleteWorkspace() {
if (!deletionPreflight && !deletionRequest) return;
deletionSubmitting = true;
deletionError = null;
try {
const request = deletionRequest ?? {
operation_id: crypto.randomUUID(),
expected_revision: deletionPreflight!.expected_revision,
confirmation: deletionConfirmation,
};
deletionRequest = request;
sessionStorage.setItem(deletionStorageKey(), JSON.stringify(request));
const operation = await startWorkspaceDeletion(workspaceId, request);
deletionOperation = operation;
await trackDeletion(operation.operation_id);
} catch (err) {
deletionError = err instanceof Error ? err.message : 'Workspace deletion failed';
} finally {
deletionSubmitting = false;
}
}
</script>
<svelte:head>
<title>Workspace settings · Yoi Workspace</title>
</svelte:head>
<section class="card settings-section" aria-labelledby="workspace-settings-title">
<header class="settings-section-header">
<div>
<p class="eyebrow">editable</p>
<h2 id="workspace-settings-title">Workspace Identity</h2>
</div>
<span class="badge success">Backend scoped</span>
</header>
{#if loading}
<p class="status-message">Loading workspace settings…</p>
{:else}
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void submitWorkspaceName(); }}>
<label>
<span>Display name</span>
<input bind:value={displayNameDraft} autocomplete="off" />
</label>
<p class="settings-note">Workspace id: <code>{workspaceMetadata?.workspace_id ?? workspaceId}</code></p>
<button type="submit" disabled={submitting || !workspaceMetadata}>{submitting ? 'Saving…' : 'Save workspace name'}</button>
</form>
<dl class="settings-identity-list">
<div>
<dt>Source</dt>
<dd>{workspaceMetadata?.source ?? 'unknown'}</dd>
</div>
<div>
<dt>Revision</dt>
<dd><code>{workspaceMetadata?.revision ?? 'unknown'}</code></dd>
</div>
</dl>
{/if}
{#if message}
<p class="status-message" class:error={message.includes('failed')}>{message}</p>
{/if}
<DiagnosticsList {diagnostics} />
</section>
{#if data.workspace?.permissions.delete_workspace}
<section class="settings-section danger-zone" aria-labelledby="workspace-danger-title">
<div>
<h2 id="workspace-danger-title">Danger zone</h2>
<p>Deleting this Workspace permanently removes its Workers, Workdirs, repositories, configuration, Memory, Tickets, and audit data.</p>
</div>
<button class="danger-button" type="button" onclick={() => void openDeletionConfirmation()}>Delete Workspace</button>
</section>
{/if}
{#if deletionOpen}
<div class="modal-backdrop" role="presentation">
<div class="deletion-dialog" role="dialog" aria-modal="true" aria-labelledby="delete-workspace-title">
<h2 id="delete-workspace-title">Delete {deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? 'Workspace'}?</h2>
{#if deletionLoading}
<p>Loading deletion impact…</p>
{:else if deletionPreflight}
<p>This operation cannot be undone. It will remove:</p>
<ul>
<li>{deletionPreflight.resources.workers} Workers</li>
<li>{deletionPreflight.resources.workdirs} Workdirs</li>
<li>{deletionPreflight.resources.repositories} repositories</li>
<li>{deletionPreflight.resources.runtime_bindings} Runtime bindings</li>
<li>{deletionPreflight.resources.secrets} secret records</li>
<li>{deletionPreflight.resources.artifacts} artifacts</li>
</ul>
{#each deletionPreflight.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
<label>
<span>Type <strong>{deletionPreflight.display_name}</strong> to confirm</span>
<input bind:value={deletionConfirmation} autocomplete="off" />
</label>
{/if}
{#if deletionOperation}
<p class="status-message">Deletion state: {deletionOperation.state}</p>
{#each deletionOperation.blockers as blocker}
<p class="status-message error">{blocker.message}</p>
{/each}
{/if}
{#if deletionError}<p class="status-message error">{deletionError}</p>{/if}
<div class="dialog-actions">
<button type="button" onclick={() => { deletionOpen = false; }} disabled={deletionSubmitting}>Cancel</button>
<button
class="danger-button"
type="button"
onclick={() => void deleteWorkspace()}
disabled={deletionSubmitting || (!deletionRequest && !deletionPreflight?.can_delete) || deletionConfirmation !== (deletionPreflight?.display_name ?? deletionRequest?.confirmation ?? '')}
>{deletionSubmitting ? 'Deleting…' : 'Delete Workspace'}</button>
</div>
</div>
</div>
{/if}
<style>
.danger-zone { display: flex; justify-content: space-between; align-items: start; gap: var(--space-4); border-top: 1px solid var(--color-danger, #b42318); }
.danger-zone p { max-width: 68ch; }
.danger-button { color: white; background: var(--color-danger, #b42318); border-color: var(--color-danger, #b42318); }
.modal-backdrop { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: var(--space-4); background: rgb(0 0 0 / 0.55); }
.deletion-dialog { width: min(34rem, 100%); max-height: calc(100vh - 2rem); overflow: auto; padding: var(--space-5); background: var(--color-surface, white); border: 1px solid var(--color-border); }
.deletion-dialog label { display: grid; gap: var(--space-2); margin-block: var(--space-4); }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-5); }
</style>
@@ -2,6 +2,7 @@ import {
parseRepositoryAccessProjection,
parseRepositorySshCredentials,
parseRepositorySshHostTrusts,
parseRepositorySshPublicKey,
RepositoryAccessSchemaError,
} from "../../src/lib/workspace/api/repository-access.ts";
@@ -59,6 +60,22 @@ const hostTrust = {
Deno.test("Repository Access parsers accept generated response contracts", () => {
assertEquals(parseRepositorySshCredentials([credential]), [credential]);
assertEquals(
parseRepositorySshPublicKey({
credential_id: "deploy-key",
current_revision: 2,
public_key_algorithm: "ssh-ed25519",
public_key_fingerprint: "SHA256:credential",
public_key: "ssh-ed25519 AAAA",
}),
{
credential_id: "deploy-key",
current_revision: 2,
public_key_algorithm: "ssh-ed25519",
public_key_fingerprint: "SHA256:credential",
public_key: "ssh-ed25519 AAAA",
},
);
assertEquals(parseRepositorySshHostTrusts([hostTrust]), [hostTrust]);
assertEquals(
parseRepositoryAccessProjection({
@@ -28,6 +28,7 @@ test("Repository Access Web code consumes workspace-api generated DTOs", () => {
assert(
loaderSource.includes("parseRepositorySshCredentials") &&
loaderSource.includes("parseRepositorySshHostTrusts") &&
loaderSource.includes("parseRepositorySshPublicKey") &&
loaderSource.includes("parseRepositoryAccessProjection"),
"loader should validate unknown JSON before exposing generated DTOs to Svelte",
);
@@ -66,6 +67,29 @@ test("Repository Access renders the shared access projection fields", () => {
}
});
test("Repository Access generates and copies selectable public keys", () => {
for (
const token of [
"/credentials/generate",
"/public-key",
"Generate Repository SSH credential",
"navigator.clipboard.writeText",
"publicKeys[credential.credential_id]",
"workspace-default",
"always offered during SSH clone",
]
) {
assert(
source.includes(token),
`missing generated public key flow ${token}`,
);
}
assert(
source.includes("binding.credential_id"),
"Repository bindings should identify the selected credential",
);
});
test("Repository credential submissions clear write-only fields in finally blocks", () => {
const createStart = source.indexOf("async function createCredential()");
const rotateStart = source.indexOf("async function rotateCredential(");
@@ -0,0 +1,106 @@
import { assert, assertEquals } from "jsr:@std/assert";
import type { WorkspaceRuntimeResource } from "../src/lib/generated/workspace-api.ts";
import { parseRepositorySshConnectionProbeResponse } from "../src/lib/workspace/api/workspace-model.ts";
import { repositorySshProbeRuntimes } from "../src/lib/workspace/repositories/ssh-connection.ts";
const root = new URL("../", import.meta.url);
const pageSource = await Deno.readTextFile(
new URL(
"./src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.svelte",
root,
),
);
const loaderSource = await Deno.readTextFile(
new URL(
"./src/routes/w/[workspaceId]/repositories/[repositoryKey]/+page.ts",
root,
),
);
Deno.test("Repository SSH probe parser preserves the confirmation contract", () => {
const response = {
workspace_id: "workspace-a",
repository_key: "main",
runtime_id: "runtime-a",
hostname: "example.test",
port: 22,
trust_state: "untrusted" as const,
host_trust_id: "tofu-example.test-22",
expected_host_trust_revision: null,
candidates: [
{
algorithm: "ssh-ed25519",
host_key: "ssh-ed25519 AAAA",
fingerprint: "SHA256:host",
},
],
};
assertEquals(parseRepositorySshConnectionProbeResponse(response), response);
});
Deno.test("Repository SSH probe offers configured remote Runtimes regardless of worker-style status", () => {
const configured = {
runtime_id: "arcadia",
label: "Arcadia",
kind: "remote_worker_runtime",
status: "idle",
diagnostics: [],
management: {
endpoint_configured: true,
endpoint_display: "https://arcadia.example",
binding: {
state: "verified",
},
},
} as unknown as WorkspaceRuntimeResource;
const embedded = {
...configured,
runtime_id: "embedded-worker-runtime",
kind: "embedded_worker_runtime",
} as unknown as WorkspaceRuntimeResource;
const revoked = {
...configured,
runtime_id: "revoked",
management: {
...configured.management,
binding: { state: "revoked" },
},
} as unknown as WorkspaceRuntimeResource;
const unbound = {
...configured,
runtime_id: "unbound",
management: {
...configured.management,
binding: undefined,
},
} as unknown as WorkspaceRuntimeResource;
assertEquals(
repositorySshProbeRuntimes([embedded, configured, revoked, unbound]).map((
runtime,
) => runtime.runtime_id),
["arcadia"],
);
});
Deno.test("Repository SSH connection test requires an explicit host-key confirmation", () => {
for (
const token of [
"Check SSH connection",
"selectedRuntimeId",
"candidate.fingerprint",
"Confirm and trust selected host key",
"expected_host_trust_revision",
"requestConnectionTest('POST'",
"requestConnectionTest('PUT'",
]
) {
assert(pageSource.includes(token), `missing SSH connection flow ${token}`);
}
});
Deno.test("Repository detail loads configured Workspace Runtimes for the connection test", () => {
assert(loaderSource.includes('workspaceApiPath(workspaceId, "/runtimes")'));
assert(loaderSource.includes("parseWorkspaceRuntimeList"));
});
+108 -2
View File
@@ -26,9 +26,12 @@ function assertThrows<T extends Error>(
import {
fetchProfileSettings,
fetchWorkspaceMetadata,
fetchWorkspaceSigningIdentity,
parseProfileSettingsResponse,
parseWorkspaceMetadataSettingsResponse,
parseWorkspaceSigningIdentityResponse,
ProfileApiError,
provisionWorkspaceSigningIdentity,
updateWorkspaceMetadata,
} from "../src/lib/workspace/settings/profile-api.ts";
@@ -126,8 +129,8 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => {
"workspace 1",
);
assertEquals(requests.map((request) => request.url), [
"/api/w/workspace%201/settings/workspace",
"/api/w/workspace%201/settings/workspace",
"/api/w/workspace%201/settings",
"/api/w/workspace%201/settings",
]);
assertEquals(requests[1].init?.method, "PUT");
assertEquals(
@@ -139,6 +142,37 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => {
}
});
Deno.test("Workspace signing identity requests use flat settings routes", async () => {
const originalFetch = globalThis.fetch;
const requests: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = (input: string | URL | Request, init?: RequestInit) => {
requests.push({ url: String(input), init });
return Promise.resolve(Response.json({
identity: {
workspace_id: "workspace 1",
key_id: "workspace-signing-key",
algorithm: "ed25519",
revision: 1,
state: "pending_provisioning",
created_at: "2026-01-01T00:00:00Z",
},
}));
};
try {
await fetchWorkspaceSigningIdentity("workspace 1");
await provisionWorkspaceSigningIdentity("workspace 1");
assertEquals(requests.map((request) => request.url), [
"/api/w/workspace%201/settings/signing-identity",
"/api/w/workspace%201/settings/signing-identity/provision",
]);
assertEquals(requests[0].init, undefined);
assertEquals(requests[1].init?.method, "POST");
} finally {
globalThis.fetch = originalFetch;
}
});
Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid provenance fields", () => {
const missing = profileSettingsFixture();
delete missing.profiles;
@@ -170,6 +204,78 @@ Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid
);
});
Deno.test("Workspace signing identity parser validates active and pending public contracts", () => {
const active = {
identity: {
workspace_id: "workspace-1",
key_id: "WK-1",
algorithm: "ed25519",
public_key: "public-key",
public_key_fingerprint: "sha256:fingerprint",
revision: 1,
state: "active",
created_at: "2026-01-01T00:00:00Z",
provisioned_at: "2026-01-01T00:00:00Z",
},
public_bundle: {
workspace_id: "workspace-1",
backend_url: "https://backend.example.test",
key_id: "WK-1",
algorithm: "ed25519",
public_key: "public-key",
public_key_fingerprint: "sha256:fingerprint",
revision: 1,
},
};
assertEquals(
parseWorkspaceSigningIdentityResponse(active).public_bundle?.key_id,
"WK-1",
);
assertEquals(
parseWorkspaceSigningIdentityResponse({
identity: {
workspace_id: "workspace-1",
key_id: "WK-1",
algorithm: "ed25519",
revision: 1,
state: "pending_provisioning",
created_at: "2026-01-01T00:00:00Z",
},
}).public_bundle,
undefined,
);
for (
const mutate of [
(value: Record<string, unknown>) => {
value.private_material_ref = "must-not-be-accepted";
},
(value: Record<string, unknown>) => {
(value.identity as Record<string, unknown>).revision =
Number.MAX_SAFE_INTEGER + 1;
},
(value: Record<string, unknown>) => {
(value.identity as Record<string, unknown>).public_key = "x".repeat(
17_000,
);
},
(value: Record<string, unknown>) => {
(value.public_bundle as Record<string, unknown>).key_id = "WK-other";
},
(value: Record<string, unknown>) => {
delete value.public_bundle;
},
]
) {
const value = structuredClone(active);
mutate(value);
assertThrows(
() => parseWorkspaceSigningIdentityResponse(value),
ProfileApiError,
);
}
});
Deno.test("workspace metadata parser rejects incomplete or stale response fields", () => {
assertThrows(
() =>
@@ -19,6 +19,9 @@ function compatibleResponse(): Record<string, unknown> {
return {
workspace_id: "workspace-a",
runtime_id: "runtime-a",
binding_revision: 3,
connection_state: "verified",
verification: null,
checked_at: "2026-09-01T12:00:00Z",
status: "compatible",
failure_kind: null,
@@ -57,6 +60,23 @@ Deno.test("runtime connection response rejects unknown fields and incoherent com
}),
null,
);
assertEquals(
parseRuntimeConnectionTestResponse({
...compatibleResponse(),
verification: {
verified_at: "2026-09-01T12:00:00Z",
last_checked_at: "2026-09-01T12:00:01Z",
last_outcome: "verified",
binding_revision: 2,
workspace_key_id: "WK-a",
workspace_identity_revision: 1,
workspace_trust_generation: 1,
runtime_public_key_fingerprint: "sha256:runtime",
runtime_identity_revision: 1,
},
}),
null,
);
});
Deno.test("runtime connection response rejects unknown failure kinds and unbounded diagnostics", () => {
@@ -27,6 +27,11 @@ Deno.test("Runtime Settings routes validate unknown JSON through the shared Runt
listLoader.includes("parseWorkspaceRuntimeList(value)"),
"Runtime list loader should validate unknown JSON",
);
assert(
listLoader.includes('"/settings/signing-identity"') &&
!listLoader.includes("/settings/workspace"),
"Runtime list loader should use the canonical Workspace signing identity route",
);
assert(
detailLoader.includes("parseWorkspaceRuntimeDetail(value)"),
"Runtime detail loader should validate unknown JSON",
@@ -39,6 +44,48 @@ Deno.test("Runtime Settings routes validate unknown JSON through the shared Runt
}
});
Deno.test("Runtime registration presents the complete multi-Workspace trust sequence", async () => {
const page = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/runtimes/+page.svelte",
import.meta.url,
),
);
for (
const token of [
"1. Trust this Workspace on the Runtime",
"Provision Workspace identity",
"provisionWorkspaceSigningIdentity(data.workspaceId)",
"Existing Workspace trust entries are preserved.",
"--fs-root",
"--fs-runtime-dir",
"2. Verify the Runtime identity",
"yoi-runtime identity show --json",
"3. Register the connection",
"Register Runtime",
"Run Test to complete authenticated verification.",
]
) {
assert(
page.includes(token),
`Runtime registration should include ${token}`,
);
}
assert(
page.includes(
"data.signingIdentity?.identity.state === 'pending_provisioning'",
) &&
page.includes("Loading Workspace public identity…"),
"pending identity must have a dedicated provisioning state before loading fallback",
);
assert(
!page.includes("fingerprintConfirmation") &&
!page.includes("Confirm Runtime fingerprint"),
"Runtime registration must not require retyping a fingerprint",
);
});
Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => {
const page = await Deno.readTextFile(
new URL(
@@ -106,22 +153,26 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"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.",
"await revokeRuntimeTrustKey(",
"deleteRemoteRuntime(data.workspaceId, operation.runtimeId)",
"Revoke trust and delete registration",
"trust.status !== 'revoked'",
"deleteRuntimeConfirmation.trim() !== data.runtimeId",
"Delete Runtime registration",
"Delete registration",
"This does not stop the Runtime process",
"RuntimeTrustConflictError",
"RuntimeTrustRouteFence",
"routeFence.enter(data.runtimeId)",
"showPublicKey = false",
"revealedPublicKey = null",
"publicKey = ''",
"fingerprintConfirmation = ''",
"revokeFingerprintConfirmation = ''",
"requestError = null",
"successMessage = null",
"isCurrentRoute(operation)",
"revealRuntimeTrustKey",
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
"await reloadAuthority()",
"busyAction !== null",
"Workdirs",
@@ -130,6 +181,15 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
) {
assert(page.includes(token), `Runtime detail should include ${token}`);
}
for (
const token of [
"fingerprintConfirmation",
"revokeFingerprintConfirmation",
"Confirm current fingerprint",
]
) {
assert(!page.includes(token), `Runtime detail must not require ${token}`);
}
});
Deno.test("Runtime detail uses flat sections instead of nested cards", async () => {
+105 -32
View File
@@ -3,12 +3,13 @@ declare const Deno: {
};
import {
createRemoteRuntime,
deleteRemoteRuntime,
parseRuntimeTrustConflict,
parseRuntimeTrustKeyRevealResponse,
parseWorkspaceRuntimeDetail,
parseWorkspaceRuntimeList,
previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey,
revokeRuntimeTrustKey,
RuntimeTrustConflictError,
RuntimeTrustRouteFence,
@@ -39,6 +40,24 @@ function runtime() {
removable: false,
endpoint_configured: true,
token_ref_configured: false,
binding: {
state: "verified",
connection_state: "verified",
revision: 3,
workspace_key_id: "WK-1",
workspace_key_generation: 1,
verification: {
verified_at: "2026-09-01T13:00:00Z",
last_checked_at: "2026-09-01T13:00:00Z",
last_outcome: "verified",
binding_revision: 3,
workspace_key_id: "WK-1",
workspace_identity_revision: 1,
workspace_trust_generation: 1,
runtime_public_key_fingerprint: "SHA256:current",
runtime_identity_revision: 1,
},
},
},
runtime_id: "arcadia",
label: "Arcadia",
@@ -95,6 +114,11 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
"Runtime ID was not preserved",
);
assert(
list.items[0]?.management.binding?.state === "verified",
"binding state was not preserved",
);
const parsed = parseWorkspaceRuntimeDetail(detail());
assert(
parsed.trust_key.revision === 3,
@@ -106,6 +130,47 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
);
});
Deno.test("Runtime list parser accepts the built-in Runtime's internal binding", () => {
const embedded = runtime();
embedded.runtime_id = "embedded";
embedded.label = "Embedded Runtime";
embedded.kind = "embedded";
embedded.management.built_in = true;
embedded.management.endpoint_configured = false;
const binding = embedded.management.binding as Partial<
typeof embedded.management.binding
>;
delete binding.workspace_key_id;
delete binding.workspace_key_generation;
delete binding.verification;
const list = parseWorkspaceRuntimeList({
workspace_id: "workspace-a",
limit: 200,
items: [embedded],
source: "workspace-control-plane",
diagnostics: [],
});
assert(
list.items[0]?.management.binding?.connection_state === "verified",
"built-in Runtime binding was not preserved",
);
});
Deno.test("Runtime management parser rejects Workspace identity bindings without key metadata", () => {
const payload = detail();
const binding = payload.runtime.management.binding as Partial<
typeof payload.runtime.management.binding
>;
delete binding.workspace_key_id;
delete binding.workspace_key_generation;
binding.state = "configured";
assertThrows(
() => parseWorkspaceRuntimeDetail(payload),
"requires Workspace signing key identity metadata",
);
});
Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
assertThrows(
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }),
@@ -208,6 +273,24 @@ Deno.test("mismatched revoke fingerprint never sends a request", async () => {
assert(requests === 0, "mismatched fingerprint sent a revoke request");
});
Deno.test("Runtime registration delete uses the Workspace-scoped resource route", async () => {
let requestedUrl = "";
let requestedMethod = "";
const fetchImpl = ((input: string | URL | Request, init?: RequestInit) => {
requestedUrl = String(input);
requestedMethod = init?.method ?? "GET";
return Promise.resolve(new Response(null, { status: 204 }));
}) as typeof fetch;
await deleteRemoteRuntime("workspace a", "runtime/a", fetchImpl);
assert(
requestedUrl === "/api/w/workspace%20a/runtimes/runtime%2Fa",
`unexpected delete URL: ${requestedUrl}`,
);
assert(requestedMethod === "DELETE", "Runtime delete must use DELETE");
});
Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {
const fence = new RuntimeTrustRouteFence();
fence.enter("runtime-a");
@@ -240,48 +323,38 @@ Deno.test("Runtime public key preview matches the Server fingerprint contract",
);
});
Deno.test("typed trust conflict is validated and preserves authoritative revision", async () => {
let sentBody: unknown = null;
const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => {
sentBody = JSON.parse(String(init?.body)) as unknown;
return Promise.resolve(
Deno.test("Runtime create surfaces bounded Settings error details", async () => {
const fetchImpl = (() =>
Promise.resolve(
new Response(
JSON.stringify({
error: "stale_revision",
message: "Runtime trust changed",
current_revision: 4,
current_fingerprint: "SHA256:new",
error: "remote_runtime_endpoint_not_allowed",
details: "Runtime endpoint must use public https egress",
}),
{ status: 409, headers: { "content-type": "application/json" } },
{ status: 400, headers: { "content-type": "application/json" } },
),
);
}) as typeof fetch;
)) as typeof fetch;
try {
await putRuntimeTrustKey(
await createRemoteRuntime(
"workspace-a",
"arcadia",
{ public_key: "ssh-ed25519 AAAA-new", expected_revision: 3 },
{
public_bundle: {
identity_id: "runtime-a",
public_key: "yoi-ed25519-pub:v1:test",
},
display_name: null,
endpoint: "https://runtime.example",
expected_revision: null,
},
fetchImpl,
);
throw new Error("expected mutation to reject");
throw new Error("expected create to reject");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
assert(
error instanceof RuntimeTrustConflictError,
"expected typed conflict",
);
assert(
error.conflict.current_revision === 4,
"authoritative revision was lost",
message === "Runtime endpoint must use public https egress",
`unexpected create error: ${message}`,
);
}
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",
);
});
+1 -1
View File
@@ -11,7 +11,7 @@ import {
const summary = {
working_directory_id: "workdir-1",
repository_key: "main",
materializer_kind: "runtime_git_cache",
materializer_kind: "runtime_git_clone",
status: "active",
occupied_by: {
runtime_id: "arcadia",
+1 -1
View File
@@ -190,7 +190,7 @@ Deno.test("Workspace deletion DTOs fail closed and preserve durable operation st
Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => {
const source = await Deno.readTextFile(
new URL(
"../src/routes/w/[workspaceId]/settings/workspace/+page.svelte",
"../src/routes/w/[workspaceId]/settings/+page.svelte",
import.meta.url,
),
);