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 = [ dependencies = [
"futures-util", "futures-util",
"log", "log",
"rustls",
"rustls-pki-types",
"tokio", "tokio",
"tokio-rustls",
"tungstenite 0.29.0", "tungstenite 0.29.0",
"webpki-roots 0.26.11",
] ]
[[package]] [[package]]
@@ -5395,6 +5399,8 @@ dependencies = [
"httparse", "httparse",
"log", "log",
"rand 0.9.4", "rand 0.9.4",
"rustls",
"rustls-pki-types",
"sha1", "sha1",
"thiserror 2.0.18", "thiserror 2.0.18",
] ]
@@ -6133,6 +6139,24 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
] ]
[[package]]
name = "webpki-roots"
version = "0.26.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9"
dependencies = [
"webpki-roots 1.0.9",
]
[[package]]
name = "webpki-roots"
version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
dependencies = [
"rustls-pki-types",
]
[[package]] [[package]]
name = "weezl" name = "weezl"
version = "0.1.12" version = "0.1.12"
+1 -1
View File
@@ -913,7 +913,7 @@ mod tests {
"working_directory": { "working_directory": {
"working_directory_id": "wd-1", "working_directory_id": "wd-1",
"repository_key": "main", "repository_key": "main",
"materializer_kind": "local_git_worktree", "materializer_kind": "runtime_git_clone",
"status": "active", "status": "active",
"occupied_by": { "occupied_by": {
"runtime_id": "arcadia", "runtime_id": "arcadia",
+4 -16
View File
@@ -12,10 +12,10 @@ use workspace_api::{
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
ObjectiveStateRequest, ObjectiveSummary, PutRuntimeTrustKeyRequest, ObjectiveStateRequest, ObjectiveSummary, RevokeRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, RuntimeTrustKeyRevealResponse, RuntimeTrustKeyRevealResponse, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail,
WorkerLaunchOptionsResponse, WorkspaceRuntimeDetail, WorkspaceRuntimeResource, WorkspaceRuntimeResource,
}; };
use crate::{BackendApiClient, BackendWorkspaceClientError}; use crate::{BackendApiClient, BackendWorkspaceClientError};
@@ -266,18 +266,6 @@ impl BackendWorkspaceProductClient {
)) ))
} }
pub fn put_runtime_trust_key(
&self,
runtime_id: &str,
request: &PutRuntimeTrustKeyRequest,
) -> Result<WorkspaceRuntimeDetail, BackendWorkspaceClientError> {
self.send_json(
Method::PUT,
&format!("/runtimes/{}/trust-key", encode_path_segment(runtime_id)),
Some(request),
)
}
pub fn revoke_runtime_trust_key( pub fn revoke_runtime_trust_key(
&self, &self,
runtime_id: &str, runtime_id: &str,
+4 -12
View File
@@ -3,7 +3,7 @@ use std::path::{Path, PathBuf};
use globset::Glob; use globset::Glob;
use ignore::WalkBuilder; 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. /// Execute a bounded glob entirely inside the provider process.
pub fn run_glob( pub fn run_glob(
@@ -18,21 +18,13 @@ pub fn run_glob(
if !access.is_readable(base) { if !access.is_readable(base) {
return Err(FsError::OutOfScope(PathBuf::from(request.path.as_str()))); 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) let matcher = Glob::new(&request.pattern)
.map_err(|error| FsError::InvalidGlob(error.to_string()))? .map_err(|error| FsError::InvalidGlob(error.to_string()))?
.compile_matcher(); .compile_matcher();
let mut matches = Vec::new(); 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(); let path = entry.path();
if !path.is_file() || !access.is_readable(path) { if !path.is_file() || !access.is_readable(path) {
continue; continue;
+26 -8
View File
@@ -477,13 +477,14 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[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; use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let root = temp.path().canonicalize().unwrap(); let root = temp.path().canonicalize().unwrap();
let readable = RootAccess(root.clone()); let readable = RootAccess(root.clone());
std::fs::create_dir(root.join("target-dir")).unwrap(); 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(); 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-file.rs"), root.join("file-link.rs")).unwrap();
symlink(root.join("target-dir"), root.join("directory-link")).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_eq!(file_result.match_count, 1);
assert!(file_result.output.starts_with("file-link.rs\n")); assert!(file_result.output.starts_with("file-link.rs\n"));
let directory_error = run_grep( let directory_result = run_grep(
&root, &root,
root.join("directory-link"), root.join("directory-link"),
request("directory-link"), request("directory-link"),
&readable, &readable,
) )
.unwrap_err(); .unwrap();
assert!(matches!( assert_eq!(directory_result.match_count, 1);
directory_error, assert!(
FsError::SymlinkDirectoryNotTraversed { tool: "Grep", path, .. } directory_result
if path == root.join("directory-link") .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( let broken_error = run_grep(
&root, &root,
+9 -8
View File
@@ -45,7 +45,7 @@ pub fn run_read(
) -> Result<ReadResult, FsError> { ) -> Result<ReadResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; 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))?; let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() { if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); return Err(FsError::IsDirectory(PathBuf::from(logical.as_str())));
@@ -99,7 +99,7 @@ pub fn run_write(
let path = resolve(root, &logical)?; let path = resolve(root, &logical)?;
let created = !path.exists(); let created = !path.exists();
if 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))?; let metadata = fs::metadata(&target).map_err(|error| map_io(&logical, error))?;
if metadata.is_dir() { if metadata.is_dir() {
return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); 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())) FsError::InvalidArgument(format!("{} has no parent", logical.as_str()))
})?; })?;
let parent_logical = logical_parent(&logical); 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)?; atomic_write(&path, &request.content, &logical)?;
} }
Ok(WriteResult { Ok(WriteResult {
@@ -133,7 +133,7 @@ pub fn run_edit(
) -> Result<EditResult, FsError> { ) -> Result<EditResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; 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 bytes = fs::read(&target).map_err(|error| map_io(&logical, error))?;
let actual_hash = hash_bytes(&bytes); let actual_hash = hash_bytes(&bytes);
if actual_hash != request.expected_hash { if actual_hash != request.expected_hash {
@@ -173,7 +173,7 @@ pub fn run_list(
) -> Result<ListResult, FsError> { ) -> Result<ListResult, FsError> {
let logical = request.path; let logical = request.path;
let path = resolve(root, &logical)?; 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))?; let metadata = fs::metadata(&path).map_err(|error| map_io(&logical, error))?;
if !metadata.is_dir() { if !metadata.is_dir() {
return Err(FsError::NotDirectory(PathBuf::from(logical.as_str()))); return Err(FsError::NotDirectory(PathBuf::from(logical.as_str())));
@@ -247,6 +247,7 @@ fn require_access(
logical: &FsPath, logical: &FsPath,
access: &dyn FsAccessPolicy, access: &dyn FsAccessPolicy,
write: bool, write: bool,
allow_symlink_directory: bool,
) -> Result<PathBuf, FsError> { ) -> Result<PathBuf, FsError> {
if let Some(info) = direct_symlink(path) { if let Some(info) = direct_symlink(path) {
if !info.target_exists { if !info.target_exists {
@@ -257,9 +258,9 @@ fn require_access(
}); });
} }
let allowed = if write { let allowed = if write {
access.is_writable(&info.resolved_path) access.is_writable(path)
} else { } else {
access.is_readable(&info.resolved_path) access.is_readable(path)
}; };
if !allowed { if !allowed {
return Err(FsError::SymlinkOutOfScope { return Err(FsError::SymlinkOutOfScope {
@@ -268,7 +269,7 @@ fn require_access(
required_permission: if write { "write" } else { "read" }, 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 { return Err(FsError::SymlinkTargetIsDirectory {
path: PathBuf::from(logical.as_str()), path: PathBuf::from(logical.as_str()),
target: PathBuf::from("<provider-internal target>"), target: PathBuf::from("<provider-internal target>"),
-10
View File
@@ -259,16 +259,6 @@ pub fn run_grep(
base.display() 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 filter_base = if base_meta.is_file() { root } else { &base };
let types = build_types(p.file_type.as_deref())?; let types = build_types(p.file_type.as_deref())?;
let overrides = build_overrides(filter_base, p.glob.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 //! Built from [`crate::ScopeConfig`] via [`Scope::from_config`]. Every
//! rule `target` must already be an absolute path — per-layer path //! rule `target` must already be an absolute path — per-layer path
//! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`]. //! resolution runs earlier, inside [`crate::WorkerManifestConfig::resolve_paths`].
//! All rule `target` paths inside the [`Scope`] are canonicalised (where //! All rule `target` paths inside the [`Scope`] are normalized lexically so
//! possible) so access checks are pure path comparisons. //! 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::{Component, Path, PathBuf};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use arc_swap::{ArcSwap, Guard}; use arc_swap::{ArcSwap, Guard};
@@ -26,7 +26,7 @@ pub struct Scope {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
struct ResolvedRule { struct ResolvedRule {
/// Absolute, canonicalized-or-normalized target directory/file. /// Absolute, lexically normalized target directory/file.
target: PathBuf, target: PathBuf,
permission: Permission, permission: Permission,
recursive: bool, recursive: bool,
@@ -201,9 +201,14 @@ impl Scope {
} }
/// Convenience constructor for tests and simple setups: a single /// 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> { 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 { Ok(Self {
allow: vec![ResolvedRule { allow: vec![ResolvedRule {
target: root, target: root,
@@ -214,8 +219,7 @@ impl Scope {
}) })
} }
/// Resolve one rule target with the same symlink and missing-tail semantics /// Return one rule's lexically normalized target without resolving symlinks.
/// used by scope matching.
pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> { pub fn resolved_target(rule: &ScopeRule) -> Result<PathBuf, ScopeError> {
Ok(resolve_rule(rule)?.target) Ok(resolve_rule(rule)?.target)
} }
@@ -244,7 +248,7 @@ impl Scope {
/// Returns `None` when `path` is outside every allow rule, or when /// Returns `None` when `path` is outside every allow rule, or when
/// deny rules have knocked it below `Read`. /// deny rules have knocked it below `Read`.
pub fn permission_at(&self, path: &Path) -> Option<Permission> { 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; let mut effective: Option<Permission> = None;
for rule in &self.allow { for rule in &self.allow {
if rule.matches(&resolved) { if rule.matches(&resolved) {
@@ -523,7 +527,7 @@ fn resolve_rule(rule: &ScopeRule) -> Result<ResolvedRule, ScopeError> {
if !rule.target.is_absolute() { if !rule.target.is_absolute() {
return Err(ScopeError::RelativeTarget(rule.target.clone())); 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(), path: rule.target.clone(),
source: std::io::Error::new(std::io::ErrorKind::Other, "could not absolutize target"), 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. /// Normalize an absolute path for lexical scope comparison without consulting
/// /// filesystem metadata or resolving symbolic links.
/// Tries `canonicalize` on the full path first (resolves symlinks). If fn normalize_path(path: &Path) -> Option<PathBuf> {
/// 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> {
if !path.is_absolute() { if !path.is_absolute() {
return None; return None;
} }
if let Ok(canonical) = path.canonicalize() { let mut normalized = PathBuf::new();
return Some(canonical); for component in path.components() {
} match component {
let mut tail: Vec<OsString> = Vec::new(); Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
let mut cur = path.to_path_buf(); Component::RootDir => normalized.push(component.as_os_str()),
loop { Component::CurDir => {}
if let Ok(canonical) = cur.canonicalize() { Component::ParentDir => {
let mut out = canonical; if !normalized.pop() {
for segment in tail.iter().rev() { return None;
out.push(segment); }
} }
return Some(out); Component::Normal(part) => normalized.push(part),
} }
let name = cur.file_name()?.to_os_string();
tail.push(name);
let parent = cur.parent()?.to_path_buf();
if parent == cur {
return None;
}
cur = parent;
} }
normalized.is_absolute().then_some(normalized)
} }
#[cfg(test)] #[cfg(test)]
@@ -805,6 +799,23 @@ mod tests {
assert!(!scope.is_readable(&traversal)); 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] #[test]
fn summary_lists_readable_and_writable() { fn summary_lists_readable_and_writable() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
+111 -6
View File
@@ -191,8 +191,7 @@ impl StandaloneWorkerStore {
StandaloneStoreError::Io(error) StandaloneStoreError::Io(error)
} }
})?; })?;
let record: StandaloneWorkerRecord = serde_json::from_slice(&bytes) let record = decode_worker_record(id, &bytes)?;
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })?;
if record.schema_version > SCHEMA_VERSION { if record.schema_version > SCHEMA_VERSION {
return Err(StandaloneStoreError::NewerSchema { return Err(StandaloneStoreError::NewerSchema {
id, id,
@@ -408,7 +407,7 @@ impl StandaloneWorkerStore {
.create_new(true) .create_new(true)
.open(&temporary) .open(&temporary)
.map_err(StandaloneStoreError::Io)?; .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.write_all(b"\n").map_err(StandaloneStoreError::Io)?;
file.sync_all().map_err(StandaloneStoreError::Io)?; file.sync_all().map_err(StandaloneStoreError::Io)?;
fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?; fs::rename(&temporary, dir.join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
@@ -428,8 +427,7 @@ impl StandaloneWorkerStore {
) -> Result<StandaloneWorkerRecord, StandaloneStoreError> { ) -> Result<StandaloneWorkerRecord, StandaloneStoreError> {
let bytes = let bytes =
fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?; fs::read(self.worker_dir(id).join(RECORD_FILE)).map_err(StandaloneStoreError::Io)?;
serde_json::from_slice(&bytes) decode_worker_record(id, &bytes)
.map_err(|source| StandaloneStoreError::CorruptRecord { id, source })
} }
fn worker_dir(&self, id: WorkerId) -> PathBuf { 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> { fn now_unix_ms() -> Result<u64, StandaloneStoreError> {
let duration = SystemTime::now() let duration = SystemTime::now()
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
@@ -709,7 +751,70 @@ pub enum StandaloneStoreError {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn lease_liveness_requires_positive_live_or_stale_evidence() { 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!({ serde_json::from_value(serde_json::json!({
"working_directory_id": "001a06a9f0202000000", "working_directory_id": "001a06a9f0202000000",
"repository_key": "main", "repository_key": "main",
"materializer_kind": "local_git_worktree", "materializer_kind": "runtime_git_clone",
"status": "active", "status": "active",
"cleanliness": "clean" "cleanliness": "clean"
})) }))
@@ -518,7 +518,7 @@ mod tests {
serde_json::from_value(serde_json::json!({ serde_json::from_value(serde_json::json!({
"working_directory_id": "workdir-1", "working_directory_id": "workdir-1",
"repository_key": "main", "repository_key": "main",
"materializer_kind": "local_git_worktree", "materializer_kind": "runtime_git_clone",
"status": "active" "status": "active"
})) }))
.unwrap(), .unwrap(),
+1
View File
@@ -14,6 +14,7 @@ fs-operation.workspace = true
manifest.workspace = true manifest.workspace = true
reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true } reqwest = { version = "0.13", default-features = false, features = ["json", "rustls"], optional = true }
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
serde_json.workspace = true
sha2.workspace = true sha2.workspace = true
tempfile.workspace = true tempfile.workspace = true
thiserror.workspace = true thiserror.workspace = true
+26 -7
View File
@@ -293,7 +293,12 @@ mod client {
/// implementations can mint short-lived capability tokens without making a /// implementations can mint short-lived capability tokens without making a
/// Worker-bound session expire with the token used to open it. /// Worker-bound session expire with the token used to open it.
pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync { pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync {
fn bearer_token(&self) -> Result<String, WorkdirError>; fn bearer_token(
&self,
method: &str,
path_and_query: &str,
body: &[u8],
) -> Result<String, WorkdirError>;
} }
struct FixedBearerToken(Arc<str>); struct FixedBearerToken(Arc<str>);
@@ -305,7 +310,12 @@ mod client {
} }
impl WorkdirHttpAuthorization for FixedBearerToken { impl WorkdirHttpAuthorization for FixedBearerToken {
fn bearer_token(&self) -> Result<String, WorkdirError> { fn bearer_token(
&self,
_method: &str,
_path_and_query: &str,
_body: &[u8],
) -> Result<String, WorkdirError> {
Ok(self.0.to_string()) Ok(self.0.to_string())
} }
} }
@@ -354,10 +364,14 @@ mod client {
&base_url, &base_url,
&["v1", "working-directories", workdir_id.as_str(), "sessions"], &["v1", "working-directories", workdir_id.as_str(), "sessions"],
)?; )?;
let body = serde_json::to_vec(&request)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
let token = authorization.bearer_token("POST", url.path(), &body)?;
let response = client let response = client
.post(url) .post(url)
.bearer_auth(authorization.bearer_token()?) .bearer_auth(token)
.json(&request) .header("content-type", "application/json")
.body(body)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
@@ -401,11 +415,15 @@ mod client {
], ],
)?; )?;
let operation = WorkdirSessionOperationRequest { operation }; let operation = WorkdirSessionOperationRequest { operation };
let body = serde_json::to_vec(&operation)
.map_err(|error| WorkdirError::Unavailable(error.to_string()))?;
let token = self.authorization.bearer_token("POST", url.path(), &body)?;
let response = self let response = self
.client .client
.post(url) .post(url)
.bearer_auth(self.authorization.bearer_token()?) .bearer_auth(token)
.json(&operation) .header("content-type", "application/json")
.body(body)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
@@ -543,10 +561,11 @@ mod client {
&self.base_url, &self.base_url,
&["v1", "workdir-sessions", self.session_id.as_str()], &["v1", "workdir-sessions", self.session_id.as_str()],
)?; )?;
let token = self.authorization.bearer_token("DELETE", url.path(), &[])?;
let response = self let response = self
.client .client
.delete(url) .delete(url)
.bearer_auth(self.authorization.bearer_token()?) .bearer_auth(token)
.send() .send()
.await .await
.map_err(http_unavailable)?; .map_err(http_unavailable)?;
+73 -18
View File
@@ -1635,7 +1635,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[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; use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1646,15 +1646,7 @@ mod tests {
symlink(&target, &link).unwrap(); symlink(&target, &link).unwrap();
let fs = make_fs(&dir); let fs = make_fs(&dir);
let err = fs.read_bytes(&link).unwrap_err(); assert_eq!(fs.read_bytes(&link).unwrap(), b"secret");
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:?}"
);
} }
#[cfg(unix)] #[cfg(unix)]
@@ -1746,7 +1738,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[test] #[test]
fn write_reports_symlink_target_outside_scope() { fn write_allows_logical_symlink_path_with_target_outside_scope() {
use std::os::unix::fs::symlink; use std::os::unix::fs::symlink;
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
@@ -1757,14 +1749,13 @@ mod tests {
symlink(&target, &link).unwrap(); symlink(&target, &link).unwrap();
let fs = make_fs(&dir); 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!( assert!(
matches!( fs::symlink_metadata(&link)
err, .unwrap()
WorkdirError::SymlinkOutOfScope { ref path, target: ref err_target, required_permission: "write" } .file_type()
if path == &link && err_target == &target.canonicalize().unwrap() .is_symlink()
),
"expected write symlink out-of-scope diagnostic, got {err:?}"
); );
} }
@@ -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] #[tokio::test]
async fn provider_executes_glob_grep_and_command_at_the_materialization() { async fn provider_executes_glob_grep_and_command_at_the_materialization() {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
+35 -75
View File
@@ -492,51 +492,9 @@ impl ScopedWorkdirSession {
} }
} }
async fn ensure_source_path_has_no_symlink(&self, path: &FsPath) -> Result<(), WorkdirError> { fn resolve_operation_path(&self, path: &FsPath) -> Result<FsPath, 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> {
self.ensure_active()?; self.ensure_active()?;
let resolved = self.resolve_path(path)?; self.resolve_path(path)
if self.scope.is_some() {
self.ensure_source_path_has_no_symlink(&resolved).await?;
}
Ok(resolved)
} }
fn validate_scope( fn validate_scope(
@@ -624,8 +582,6 @@ impl ScopedWorkdirSession {
request.cwd request.cwd
))); )));
} }
self.ensure_scope_targets_do_not_traverse_symlinks(&request.rules)
.await?;
let validity = SessionValidity::child(self.validity.clone()); let validity = SessionValidity::child(self.validity.clone());
let cleanup_pending = Arc::new(AtomicBool::new(true)); let cleanup_pending = Arc::new(AtomicBool::new(true));
let id = self.next_lease_id.fetch_add(1, Ordering::Relaxed); 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> { 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)?; self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path; request.path = path;
self.source.stat(request).await self.source.stat(request).await
} }
async fn read(&self, mut request: ReadRequest) -> Result<ReadResult, WorkdirError> { 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)?; self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path; request.path = path;
self.source.read(request).await self.source.read(request).await
} }
async fn write(&self, mut request: WriteRequest) -> Result<WriteResult, WorkdirError> { 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)?; self.ensure_write(&path, WorkdirSessionCapability::Write)?;
request.path = path; request.path = path;
self.source.write(request).await self.source.write(request).await
} }
async fn edit(&self, mut request: EditRequest) -> Result<EditResult, WorkdirError> { 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)?; self.ensure_write(&path, WorkdirSessionCapability::Edit)?;
request.path = path; request.path = path;
self.source.edit(request).await self.source.edit(request).await
} }
async fn list(&self, mut request: ListRequest) -> Result<ListResult, WorkdirError> { 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)?; self.ensure_read(&path, WorkdirSessionCapability::Read)?;
request.path = path; request.path = path;
self.source.list(request).await self.source.list(request).await
} }
async fn glob(&self, mut request: GlobRequest) -> Result<GlobResult, WorkdirError> { 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)?; self.ensure_read(&path, WorkdirSessionCapability::Glob)?;
request.path = path; request.path = path;
self.source.glob(request).await self.source.glob(request).await
} }
async fn grep(&self, mut request: GrepRequest) -> Result<GrepResult, WorkdirError> { 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)?; self.ensure_read(&path, WorkdirSessionCapability::Grep)?;
request.path = path; request.path = path;
self.source.grep(request).await self.source.grep(request).await
@@ -1487,7 +1443,7 @@ mod tests {
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[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; use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -1501,16 +1457,12 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let result = child.read(read("link")).await; assert_eq!(child.read(read("link")).await.unwrap().bytes, b"hidden");
assert!(
result.is_err(),
"symlink read escaped provider scope: {result:?}"
);
} }
#[cfg(unix)] #[cfg(unix)]
#[tokio::test] #[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; use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -1523,17 +1475,19 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let result = child.write(write("outside/new", "forbidden")).await; child
assert!( .write(write("outside/new", "through-logical-path"))
result.is_err(), .await
"symlink write escaped provider scope: {result:?}" .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)] #[cfg(unix)]
#[tokio::test] #[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; use std::os::unix::fs::symlink;
let root = TempDir::new().unwrap(); let root = TempDir::new().unwrap();
@@ -1542,19 +1496,25 @@ mod tests {
symlink("../secret", root.path().join("granted/outside")).unwrap(); symlink("../secret", root.path().join("granted/outside")).unwrap();
let parent = session(root.path()); let parent = session(root.path());
assert!(matches!( let child = parent
parent .scope(request(
.scope(request( "granted/outside",
"granted/outside", WorkdirToolScopePermission::Write,
WorkdirToolScopePermission::Write ))
)) .await
.await, .unwrap();
Err(WorkdirError::Denied(_)) child
)); .write(write("from-child", "child-authoritative"))
.await
.unwrap();
parent parent
.write(write("secret/parent", "still-authoritative")) .write(write("secret/parent", "still-authoritative"))
.await .await
.unwrap(); .unwrap();
assert_eq!(
fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
"child-authoritative"
);
} }
#[tokio::test] #[tokio::test]
+1 -1
View File
@@ -39,7 +39,7 @@ reqwest = { version = "0.13", optional = true, default-features = false, feature
ring.workspace = true ring.workspace = true
tar.workspace = true tar.workspace = true
thiserror = { 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.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
toml.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 base64::engine::general_purpose::URL_SAFE_NO_PAD;
use ring::rand::{SecureRandom, SystemRandom}; use ring::rand::{SecureRandom, SystemRandom};
use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey}; use ring::signature::{ED25519, Ed25519KeyPair, KeyPair, UnparsedPublicKey};
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::fmt; use std::fmt;
@@ -9,8 +10,6 @@ use std::time::{SystemTime, UNIX_EPOCH};
const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:"; const PUBLIC_KEY_PREFIX: &str = "yoi-ed25519-pub:v1:";
const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:"; const PRIVATE_KEY_PREFIX: &str = "yoi-ed25519-pkcs8:v1:";
const TOKEN_PREFIX: &str = "yoi-cap-v1";
const SIGNING_INPUT_PREFIX: &str = "yoi-cap-v1.";
pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof"; pub const WORKER_MUTATION_SOURCE_PROOF_HEADER: &str = "x-yoi-worker-mutation-proof";
const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1"; const WORKER_MUTATION_SOURCE_PROOF_PREFIX: &str = "yoi-worker-source-v1";
const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1."; const WORKER_MUTATION_SOURCE_SIGNING_INPUT_PREFIX: &str = "yoi-worker-source-v1.";
@@ -68,6 +67,74 @@ pub enum RuntimeAuthError {
WrongMutationTarget, WrongMutationTarget,
} }
pub(crate) struct SignedJsonToken<T> {
pub payload: String,
pub signature: Vec<u8>,
pub claims: T,
}
pub(crate) fn sign_json_token<T: Serialize>(
token_prefix: &str,
signing_input_prefix: &str,
signing_key: &Ed25519KeyPair,
claims: &T,
) -> Result<String, RuntimeAuthError> {
let payload = URL_SAFE_NO_PAD.encode(serde_json::to_vec(claims)?);
let signing_input = format!("{signing_input_prefix}{payload}");
let signature = signing_key.sign(signing_input.as_bytes());
Ok(format!(
"{token_prefix}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
pub(crate) fn decode_signed_json_token<T: DeserializeOwned>(
token: &str,
expected_prefix: &str,
) -> Result<SignedJsonToken<T>, RuntimeAuthError> {
let (prefix, payload, signature) = split_three_part_token(token)?;
if prefix != expected_prefix {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let signature = URL_SAFE_NO_PAD.decode(signature)?;
let claims = serde_json::from_slice(&URL_SAFE_NO_PAD.decode(payload)?)?;
Ok(SignedJsonToken {
payload: payload.to_string(),
signature,
claims,
})
}
pub(crate) fn verify_signed_json_token(
signing_input_prefix: &str,
payload: &str,
signature: &[u8],
public_key: &str,
) -> Result<(), RuntimeAuthError> {
let public_key = decode_public_key(public_key)?;
let signing_input = format!("{signing_input_prefix}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)
}
fn split_three_part_token(token: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = token.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
pub(crate) fn is_request_body_digest(value: &str) -> bool {
URL_SAFE_NO_PAD
.decode(value)
.is_ok_and(|decoded| decoded.len() == 32 && URL_SAFE_NO_PAD.encode(decoded) == value)
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeIdentityMaterial { pub struct RuntimeIdentityMaterial {
pub identity_id: String, pub identity_id: String,
@@ -95,21 +162,6 @@ impl RuntimeIdentityMaterial {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct TrustedServerKey {
pub server_id: String,
pub public_key: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeHttpAuthConfig {
pub runtime_id: String,
#[serde(default)]
pub trusted_servers: Vec<TrustedServerKey>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeAuthContext { pub struct RuntimeAuthContext {
pub server_id: String, pub server_id: String,
@@ -119,122 +171,6 @@ pub struct RuntimeAuthContext {
pub expires_at: u64, pub expires_at: u64,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapabilityClaims {
pub iss: String,
pub aud: String,
pub workspace_id: String,
pub permissions: Vec<String>,
pub exp: u64,
pub jti: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CapabilityTokenSigner {
server_id: String,
private_key: String,
}
impl CapabilityTokenSigner {
pub fn new(server_id: impl Into<String>, private_key: impl Into<String>) -> Self {
Self {
server_id: server_id.into(),
private_key: private_key.into(),
}
}
pub fn server_id(&self) -> &str {
&self.server_id
}
pub fn sign(&self, claims: &CapabilityClaims) -> Result<String, RuntimeAuthError> {
if claims.iss != self.server_id {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss.clone()));
}
let private = decode_private_key(&self.private_key)?;
let pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let payload = serde_json::to_vec(claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
let signature = pair.sign(signing_input.as_bytes());
Ok(format!(
"{TOKEN_PREFIX}.{payload}.{}",
URL_SAFE_NO_PAD.encode(signature.as_ref())
))
}
}
pub fn capability_claims(
server_id: impl Into<String>,
runtime_id: impl Into<String>,
workspace_id: impl Into<String>,
permissions: Vec<String>,
ttl_seconds: u64,
) -> Result<CapabilityClaims, RuntimeAuthError> {
let exp = unix_now_seconds().saturating_add(ttl_seconds);
Ok(CapabilityClaims {
iss: server_id.into(),
aud: runtime_id.into(),
workspace_id: workspace_id.into(),
permissions,
exp,
jti: new_token_id()?,
})
}
pub fn verify_capability_token(
config: &RuntimeHttpAuthConfig,
token: &str,
required_permission: Option<&str>,
now_seconds: u64,
) -> Result<RuntimeAuthContext, RuntimeAuthError> {
let (payload, signature) = split_token(token)?;
let claims_json = URL_SAFE_NO_PAD.decode(payload)?;
let claims: CapabilityClaims = serde_json::from_slice(&claims_json)?;
let Some(server) = config
.trusted_servers
.iter()
.find(|server| server.server_id == claims.iss)
else {
return Err(RuntimeAuthError::UnknownIssuer(claims.iss));
};
let public_key = decode_public_key(&server.public_key)?;
let signing_input = format!("{SIGNING_INPUT_PREFIX}{payload}");
UnparsedPublicKey::new(&ED25519, public_key)
.verify(signing_input.as_bytes(), &signature)
.map_err(|_| RuntimeAuthError::InvalidSignature)?;
if claims.aud != config.runtime_id {
return Err(RuntimeAuthError::WrongAudience {
expected: config.runtime_id.clone(),
actual: claims.aud,
});
}
if claims.exp < now_seconds {
return Err(RuntimeAuthError::Expired);
}
if claims.workspace_id.trim().is_empty() {
return Err(RuntimeAuthError::MissingWorkspaceScope);
}
if let Some(required) = required_permission {
if !claims
.permissions
.iter()
.any(|permission| permission == required)
{
return Err(RuntimeAuthError::MissingPermission(required.to_string()));
}
}
Ok(RuntimeAuthContext {
server_id: claims.iss,
workspace_id: claims.workspace_id,
permissions: claims.permissions,
token_id: claims.jti,
expires_at: claims.exp,
})
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RuntimeRequestSourceClaims { pub struct RuntimeRequestSourceClaims {
pub iss: String, pub iss: String,
@@ -323,28 +259,22 @@ impl RuntimeRequestSourceSigner {
exp: now_unix.saturating_add(ttl_seconds), exp: now_unix.saturating_add(ttl_seconds),
jti: new_token_id()?, jti: new_token_id()?,
}; };
let payload = serde_json::to_vec(&claims)?;
let payload = URL_SAFE_NO_PAD.encode(payload);
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}");
let private = decode_private_key(&self.private_key)?; let private = decode_private_key(&self.private_key)?;
let key_pair = Ed25519KeyPair::from_pkcs8(&private) let key_pair = Ed25519KeyPair::from_pkcs8(&private)
.map_err(|_| RuntimeAuthError::InvalidPrivateKey)?; .map_err(|_| RuntimeAuthError::InvalidPrivateKey)?;
let signature = URL_SAFE_NO_PAD.encode(key_pair.sign(signing_input.as_bytes()).as_ref()); sign_json_token(
Ok(format!( RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
"{RUNTIME_REQUEST_SOURCE_PROOF_PREFIX}.{payload}.{signature}" RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
)) &key_pair,
&claims,
)
} }
} }
pub fn decode_runtime_request_source_claims( pub fn decode_runtime_request_source_claims(
proof: &str, proof: &str,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> { ) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, _signature) = split_runtime_request_source_proof(proof)?; Ok(decode_signed_json_token(proof, RUNTIME_REQUEST_SOURCE_PROOF_PREFIX)?.claims)
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
let payload = URL_SAFE_NO_PAD.decode(payload)?;
serde_json::from_slice(&payload).map_err(RuntimeAuthError::from)
} }
pub fn verify_runtime_request_source( pub fn verify_runtime_request_source(
@@ -352,17 +282,17 @@ pub fn verify_runtime_request_source(
public_key: &str, public_key: &str,
expected: &RuntimeRequestSourceExpectation<'_>, expected: &RuntimeRequestSourceExpectation<'_>,
) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> { ) -> Result<RuntimeRequestSourceClaims, RuntimeAuthError> {
let (prefix, payload, signature) = split_runtime_request_source_proof(proof)?; let signed = decode_signed_json_token::<RuntimeRequestSourceClaims>(
if prefix != RUNTIME_REQUEST_SOURCE_PROOF_PREFIX { proof,
return Err(RuntimeAuthError::InvalidTokenFormat); RUNTIME_REQUEST_SOURCE_PROOF_PREFIX,
} )?;
let signature = URL_SAFE_NO_PAD.decode(signature)?; verify_signed_json_token(
let signing_input = format!("{RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX}{payload}"); RUNTIME_REQUEST_SOURCE_SIGNING_INPUT_PREFIX,
let public_key = decode_public_key(public_key)?; &signed.payload,
UnparsedPublicKey::new(&ED25519, public_key) &signed.signature,
.verify(signing_input.as_bytes(), &signature) public_key,
.map_err(|_| RuntimeAuthError::InvalidSignature)?; )?;
let claims = decode_runtime_request_source_claims(proof)?; let claims = signed.claims;
if claims.iss != expected.identity_id if claims.iss != expected.identity_id
|| claims.aud != expected.audience || claims.aud != expected.audience
|| claims.workspace_id != expected.workspace_id || claims.workspace_id != expected.workspace_id
@@ -380,17 +310,6 @@ pub fn verify_runtime_request_source(
Ok(claims) Ok(claims)
} }
fn split_runtime_request_source_proof(proof: &str) -> Result<(&str, &str, &str), RuntimeAuthError> {
let mut parts = proof.split('.');
let prefix = parts.next().unwrap_or_default();
let payload = parts.next().unwrap_or_default();
let signature = parts.next().unwrap_or_default();
if prefix.is_empty() || payload.is_empty() || signature.is_empty() || parts.next().is_some() {
return Err(RuntimeAuthError::InvalidTokenFormat);
}
Ok((prefix, payload, signature))
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerMutationSourceClaims { pub struct WorkerMutationSourceClaims {
pub iss: String, pub iss: String,
@@ -586,16 +505,6 @@ fn split_worker_mutation_source_proof(token: &str) -> Result<(&str, Vec<u8>), Ru
} }
} }
fn split_token(token: &str) -> Result<(&str, Vec<u8>), RuntimeAuthError> {
let mut parts = token.split('.');
match (parts.next(), parts.next(), parts.next(), parts.next()) {
(Some(prefix), Some(payload), Some(signature), None) if prefix == TOKEN_PREFIX => {
Ok((payload, URL_SAFE_NO_PAD.decode(signature)?))
}
_ => Err(RuntimeAuthError::InvalidTokenFormat),
}
}
pub fn encode_public_key(bytes: &[u8]) -> String { pub fn encode_public_key(bytes: &[u8]) -> String {
format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes)) format!("{PUBLIC_KEY_PREFIX}{}", URL_SAFE_NO_PAD.encode(bytes))
} }
@@ -851,46 +760,4 @@ mod tests {
Err(RuntimeAuthError::Expired) Err(RuntimeAuthError::Expired)
)); ));
} }
#[test]
fn capability_token_verifies_signature_audience_expiry_and_permission() {
let server = RuntimeIdentityMaterial::generate("server-main").unwrap();
let signer = CapabilityTokenSigner::new(&server.identity_id, &server.private_key);
let claims = CapabilityClaims {
iss: "server-main".to_string(),
aud: "runtime-main".to_string(),
workspace_id: "workspace-a".to_string(),
permissions: vec!["workers:list".to_string()],
exp: 100,
jti: "token-1".to_string(),
};
let token = signer.sign(&claims).unwrap();
let auth = RuntimeHttpAuthConfig {
runtime_id: "runtime-main".to_string(),
trusted_servers: vec![TrustedServerKey {
server_id: "server-main".to_string(),
public_key: server.public_key.clone(),
display_name: None,
}],
};
let context = verify_capability_token(&auth, &token, Some("workers:list"), 99).unwrap();
assert_eq!(context.workspace_id, "workspace-a");
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:create"), 99),
Err(RuntimeAuthError::MissingPermission(permission)) if permission == "workers:create"
));
assert!(matches!(
verify_capability_token(&auth, &token, Some("workers:list"), 101),
Err(RuntimeAuthError::Expired)
));
let wrong_audience = RuntimeHttpAuthConfig {
runtime_id: "other-runtime".to_string(),
trusted_servers: auth.trusted_servers.clone(),
};
assert!(matches!(
verify_capability_token(&wrong_audience, &token, Some("workers:list"), 99),
Err(RuntimeAuthError::WrongAudience { .. })
));
}
} }
+8 -5
View File
@@ -119,9 +119,16 @@ impl std::fmt::Debug for SensitiveString {
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepositorySshMaterializationAccess { pub struct RepositorySshCredentialCandidate {
pub credential_id: String, pub credential_id: String,
pub credential_revision: u64, 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_id: String,
pub host_trust_revision: u64, pub host_trust_revision: u64,
pub access: workspace_api::RepositoryAccessMode, pub access: workspace_api::RepositoryAccessMode,
@@ -131,8 +138,6 @@ pub struct RepositorySshMaterializationAccess {
pub repository_uri: String, pub repository_uri: String,
pub secret_resource: crate::resource::BackendResourceHandle, pub secret_resource: crate::resource::BackendResourceHandle,
#[serde(skip, default)] #[serde(skip, default)]
pub private_key: SensitiveString,
#[serde(skip, default)]
pub known_hosts_entry: SensitiveString, pub known_hosts_entry: SensitiveString,
} }
@@ -143,8 +148,6 @@ pub struct RepositoryMaterializationContext {
pub operation_id: String, pub operation_id: String,
pub config_revision: u64, pub config_revision: u64,
pub config_projection_digest: String, pub config_projection_digest: String,
#[serde(default)]
pub cache_generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<RepositorySshMaterializationAccess>, pub ssh: Option<RepositorySshMaterializationAccess>,
} }
+3
View File
@@ -287,6 +287,7 @@ pub enum WorkspaceConfigFetchResult {
pub enum WorkerExecutionSpawnResult { pub enum WorkerExecutionSpawnResult {
Connected { Connected {
handle: WorkerExecutionHandle, handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>, working_directory: Option<WorkingDirectoryStatus>,
}, },
Rejected(WorkerExecutionResult), Rejected(WorkerExecutionResult),
@@ -296,10 +297,12 @@ pub enum WorkerExecutionSpawnResult {
impl WorkerExecutionSpawnResult { impl WorkerExecutionSpawnResult {
pub fn connected( pub fn connected(
handle: WorkerExecutionHandle, handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<WorkingDirectoryStatus>, working_directory: Option<WorkingDirectoryStatus>,
) -> Self { ) -> Self {
Self::Connected { Self::Connected {
handle, handle,
worker_state,
working_directory, 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")] #[cfg(feature = "fs-store")]
pub mod retention; pub mod retention;
mod runtime; mod runtime;
pub mod ssh_host_key_probe;
pub mod worker_backend; pub mod worker_backend;
pub mod worker_source; pub mod worker_source;
pub mod working_directory; pub mod working_directory;
pub mod workspace_issuer;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions}; pub use fs_store::{FsRuntimeStore, FsRuntimeStoreOptions};
File diff suppressed because it is too large Load Diff
+95 -9
View File
@@ -13,16 +13,41 @@ pub const REPOSITORY_SSH_ACCESS_CONTENT_TYPE: &str =
"application/vnd.yoi.repository-ssh-access+json"; "application/vnd.yoi.repository-ssh-access+json";
pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024; 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_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)] #[derive(Clone, Serialize, Deserialize)]
pub struct RepositorySshAccessSecret { pub struct RepositorySshAccessSecret {
pub private_key: String, pub credential_candidates: Vec<RepositorySshAccessSecretCandidate>,
pub known_hosts_entry: String, pub known_hosts_entry: String,
} }
impl Drop for RepositorySshAccessSecret { impl Drop for RepositorySshAccessSecret {
fn drop(&mut self) { fn drop(&mut self) {
zeroize::Zeroize::zeroize(&mut self.private_key);
zeroize::Zeroize::zeroize(&mut self.known_hosts_entry); 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 { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter formatter
.debug_struct("RepositorySshAccessSecret") .debug_struct("RepositorySshAccessSecret")
.field("private_key", &"[REDACTED]") .field("credential_candidates", &self.credential_candidates)
.field("known_hosts_entry", &"[REDACTED]") .field("known_hosts_entry", &"[REDACTED]")
.finish() .finish()
} }
@@ -142,6 +167,8 @@ pub enum BackendResourceError {
Oversized { max_bytes: u64, actual_bytes: u64 }, Oversized { max_bytes: u64, actual_bytes: u64 },
#[error("backend resource content type mismatch: expected {expected}, got {actual}")] #[error("backend resource content type mismatch: expected {expected}, got {actual}")]
ContentTypeMismatch { expected: String, actual: String }, ContentTypeMismatch { expected: String, actual: String },
#[error("backend resource fetch timed out")]
Timeout,
#[error("backend resource transport failed: {message}")] #[error("backend resource transport failed: {message}")]
Transport { message: String }, Transport { message: String },
#[error("backend resource response is invalid: {message}")] #[error("backend resource response is invalid: {message}")]
@@ -163,6 +190,7 @@ pub struct HttpBackendResourceClient {
bearer_token: Option<String>, bearer_token: Option<String>,
request_source_signer: Option<RuntimeRequestSourceSigner>, request_source_signer: Option<RuntimeRequestSourceSigner>,
request_source_audience: Option<String>, request_source_audience: Option<String>,
request_timeout: std::time::Duration,
client: reqwest::Client, client: reqwest::Client,
} }
@@ -174,10 +202,16 @@ impl HttpBackendResourceClient {
bearer_token, bearer_token,
request_source_signer: None, request_source_signer: None,
request_source_audience: None, request_source_audience: None,
request_timeout: DEFAULT_BACKEND_RESOURCE_FETCH_TIMEOUT,
client: reqwest::Client::new(), 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( pub fn with_runtime_request_source(
mut self, mut self,
identity: &RuntimeIdentityMaterial, identity: &RuntimeIdentityMaterial,
@@ -209,6 +243,7 @@ impl BackendResourceClient for HttpBackendResourceClient {
let mut builder = self let mut builder = self
.client .client
.post(endpoint.clone()) .post(endpoint.clone())
.timeout(self.request_timeout)
.header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::CONTENT_TYPE, "application/json")
.body(body.clone()); .body(body.clone());
if let Some(signer) = self.request_source_signer.as_ref() { if let Some(signer) = self.request_source_signer.as_ref() {
@@ -239,12 +274,15 @@ impl BackendResourceClient for HttpBackendResourceClient {
} else { } else {
builder builder
}; };
let response = builder let response = builder.send().await.map_err(|error| {
.send() if error.is_timeout() {
.await BackendResourceError::Timeout
.map_err(|err| BackendResourceError::Transport { } else {
message: err.to_string(), BackendResourceError::Transport {
})?; message: error.to_string(),
}
}
})?;
if response.status().is_success() { if response.status().is_success() {
response response
.json::<BackendResourceFetchResponse>() .json::<BackendResourceFetchResponse>()
@@ -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] #[test]
fn response_verification_detects_digest_mismatch() { fn response_verification_detects_digest_mismatch() {
let bytes = b"archive-bytes"; let bytes = b"archive-bytes";
+276 -61
View File
@@ -189,6 +189,23 @@ impl Runtime {
Ok(()) 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. /// Create or restore a filesystem-backed Runtime.
/// ///
/// The store is scoped by `options.root`; if the directory already exists, /// The store is scoped by `options.root`; if the directory already exists,
@@ -439,26 +456,51 @@ impl Runtime {
&self, &self,
ssh: &mut crate::catalog::RepositorySshMaterializationAccess, ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
) -> Result<(), RuntimeError> { ) -> 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(()); return Ok(());
} }
let (client, runtime_id) = { let (client, runtime_id) = {
let state = self.lock()?; let state = self.lock()?;
let client = state.backend_resource_client.clone().ok_or_else(|| { let client = state
RuntimeError::InvalidRequest( .workspace_backend_resource_clients
"Backend Repository access resource client is unavailable".to_string(), .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(|| { let runtime_id = state.runtime_identity.clone().ok_or_else(|| {
RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string()) RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string())
})?; })?;
(client, runtime_id) (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 let mut response = client
.0 .0
.fetch_resource(BackendResourceFetchRequest { .fetch_resource(BackendResourceFetchRequest {
handle: ssh.secret_resource.clone(), handle: ssh.secret_resource.clone(),
runtime_id, runtime_id: runtime_id.clone(),
worker_id: None, worker_id: None,
audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(), 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(), "Backend Repository SSH access resource payload was invalid".to_string(),
) )
})?; })?;
ssh.private_key = if secret.credential_candidates.len() != ssh.credential_candidates.len()
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key)); || 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 = ssh.known_hosts_entry =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.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(()) Ok(())
} }
@@ -492,10 +564,22 @@ impl Runtime {
&self, &self,
mut request: WorkingDirectoryRepositoryAccessRequest, mut request: WorkingDirectoryRepositoryAccessRequest,
) -> Result<(), RuntimeError> { ) -> Result<(), RuntimeError> {
let materialization_runtime_id = request.materialization.runtime_id.clone();
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| { let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string()) 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) self.authorize_working_directory_repository_access(request)
} }
@@ -890,11 +974,12 @@ impl Runtime {
}; };
let spawn_result = backend.spawn_worker(spawn_request); 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 { WorkerExecutionSpawnResult::Connected {
handle, handle,
worker_state,
working_directory, working_directory,
} => (handle, working_directory), } => (handle, worker_state, working_directory),
WorkerExecutionSpawnResult::Rejected(result) WorkerExecutionSpawnResult::Rejected(result)
| WorkerExecutionSpawnResult::Errored(result) => { | WorkerExecutionSpawnResult::Errored(result) => {
self.rollback_failed_create(&worker_ref)?; self.rollback_failed_create(&worker_ref)?;
@@ -950,6 +1035,7 @@ impl Runtime {
let detail = match self.commit_created_worker( let detail = match self.commit_created_worker(
&worker_ref, &worker_ref,
handle.clone(), handle.clone(),
initial_worker_state.clone(),
working_directory, working_directory,
dispatch_result, dispatch_result,
) { ) {
@@ -968,6 +1054,7 @@ impl Runtime {
match self.commit_created_worker( match self.commit_created_worker(
&worker_ref, &worker_ref,
handle.clone(), handle.clone(),
initial_worker_state,
working_directory, working_directory,
WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn), WorkerExecutionResult::accepted(WorkerExecutionOperation::Spawn),
) { ) {
@@ -1260,11 +1347,13 @@ impl Runtime {
match backend.restore_worker(request) { match backend.restore_worker(request) {
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle, handle,
worker_state,
working_directory, working_directory,
} => { } => {
self.commit_restored_worker_execution( self.commit_restored_worker_execution(
worker_ref, worker_ref,
handle, handle,
worker_state,
WorkerStatus::Idle, WorkerStatus::Idle,
working_directory, working_directory,
)?; )?;
@@ -1656,6 +1745,7 @@ impl Runtime {
&self, &self,
worker_ref: &WorkerRef, worker_ref: &WorkerRef,
handle: WorkerExecutionHandle, handle: WorkerExecutionHandle,
initial_worker_state: protocol::WorkerStateSnapshot,
working_directory: Option<CatalogWorkingDirectoryStatus>, working_directory: Option<CatalogWorkingDirectoryStatus>,
result: WorkerExecutionResult, result: WorkerExecutionResult,
) -> Result<WorkerDetail, RuntimeError> { ) -> Result<WorkerDetail, RuntimeError> {
@@ -1665,7 +1755,7 @@ impl Runtime {
worker.execution_handle = Some(handle); worker.execution_handle = Some(handle);
worker.execution_bound = true; worker.execution_bound = true;
worker.status = WorkerStatus::Idle; 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() { if let Some(snapshot) = result.worker_state.as_ref() {
let _ = worker.apply_worker_state(snapshot); let _ = worker.apply_worker_state(snapshot);
} }
@@ -2192,10 +2282,12 @@ impl Runtime {
match backend.restore_worker(request) { match backend.restore_worker(request) {
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle, handle,
worker_state,
working_directory, working_directory,
} => self.commit_restored_worker_execution( } => self.commit_restored_worker_execution(
&candidate.worker_ref, &candidate.worker_ref,
handle, handle,
worker_state,
WorkerStatus::Idle, WorkerStatus::Idle,
working_directory, working_directory,
)?, )?,
@@ -2213,6 +2305,7 @@ impl Runtime {
&self, &self,
worker_ref: &WorkerRef, worker_ref: &WorkerRef,
handle: WorkerExecutionHandle, handle: WorkerExecutionHandle,
worker_state: protocol::WorkerStateSnapshot,
status: WorkerStatus, status: WorkerStatus,
working_directory: Option<CatalogWorkingDirectoryStatus>, working_directory: Option<CatalogWorkingDirectoryStatus>,
) -> Result<(), RuntimeError> { ) -> Result<(), RuntimeError> {
@@ -2223,6 +2316,7 @@ impl Runtime {
worker.execution_handle = Some(handle); worker.execution_handle = Some(handle);
worker.execution_bound = true; worker.execution_bound = true;
worker.status = status; worker.status = status;
let _ = worker.apply_worker_state(&worker_state);
worker.restore_intent = restore_intent_for_status(worker.status); worker.restore_intent = restore_intent_for_status(worker.status);
worker.working_directory = working_directory; worker.working_directory = working_directory;
} }
@@ -2418,6 +2512,7 @@ struct RuntimeState {
status: RuntimeStatus, status: RuntimeStatus,
execution_backend: Option<WorkerExecutionBackendRef>, execution_backend: Option<WorkerExecutionBackendRef>,
backend_resource_client: Option<BackendResourceClientRef>, backend_resource_client: Option<BackendResourceClientRef>,
workspace_backend_resource_clients: BTreeMap<String, BackendResourceClientRef>,
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: u64, next_diagnostic_id: u64,
workers: BTreeMap<WorkerId, WorkerRecord>, workers: BTreeMap<WorkerId, WorkerRecord>,
@@ -2448,6 +2543,7 @@ impl RuntimeState {
status: RuntimeStatus::Running, status: RuntimeStatus::Running,
execution_backend: None, execution_backend: None,
backend_resource_client: None, backend_resource_client: None,
workspace_backend_resource_clients: BTreeMap::new(),
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
@@ -2479,6 +2575,7 @@ impl RuntimeState {
status: RuntimeStatus::Running, status: RuntimeStatus::Running,
execution_backend: None, execution_backend: None,
backend_resource_client: None, backend_resource_client: None,
workspace_backend_resource_clients: BTreeMap::new(),
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
next_diagnostic_id: 1, next_diagnostic_id: 1,
workers: BTreeMap::new(), workers: BTreeMap::new(),
@@ -2542,6 +2639,7 @@ impl RuntimeState {
status: persisted.status, status: persisted.status,
execution_backend: None, execution_backend: None,
backend_resource_client: None, backend_resource_client: None,
workspace_backend_resource_clients: BTreeMap::new(),
next_diagnostic_id, next_diagnostic_id,
workers, workers,
config_bundles: BTreeMap::new(), config_bundles: BTreeMap::new(),
@@ -3334,6 +3432,10 @@ fn repository_resource_error(error: BackendResourceError) -> RuntimeError {
"repository_access_credential_unavailable", "repository_access_credential_unavailable",
"Repository access credential lease is unavailable or already consumed", "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 { .. } => ( BackendResourceError::Transport { .. } => (
"repository_access_provider_unavailable", "repository_access_provider_unavailable",
"Repository access credential provider is unavailable", "Repository access credential provider is unavailable",
@@ -3605,8 +3707,9 @@ mod tests {
use super::*; use super::*;
use crate::catalog::{ use crate::catalog::{
ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext, ConfigBundleRef, MaterializerKind, ProfileSelector, RepositoryMaterializationContext,
RepositorySshMaterializationAccess, SensitiveString, WorkingDirectoryClaim, RepositorySshCredentialCandidate, RepositorySshMaterializationAccess, SensitiveString,
WorkingDirectoryRepository, WorkingDirectoryRequest, WorkspaceApiRef, WorkingDirectoryClaim, WorkingDirectoryRepository, WorkingDirectoryRequest,
WorkspaceApiRef,
}; };
use crate::config_bundle::{ use crate::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration, ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigDeclaration,
@@ -3660,6 +3763,10 @@ mod tests {
BackendResourceError::MissingResource, BackendResourceError::MissingResource,
"repository_access_credential_unavailable", "repository_access_credential_unavailable",
), ),
(
BackendResourceError::Timeout,
"repository_access_resource_fetch_timeout",
),
( (
BackendResourceError::Unauthorized { BackendResourceError::Unauthorized {
message: "denied".to_string(), message: "denied".to_string(),
@@ -3913,7 +4020,7 @@ mod tests {
source_fingerprint: "sha256:source".to_string(), source_fingerprint: "sha256:source".to_string(),
selector: None, selector: None,
}, },
materializer: MaterializerKind::RuntimeGitCache, materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: Some("working-directory-1".to_string()), backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext { materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
@@ -3921,10 +4028,12 @@ mod tests {
operation_id: "operation-1".to_string(), operation_id: "operation-1".to_string(),
config_revision: 1, config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess { ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(), credential_candidates: vec![RepositorySshCredentialCandidate {
credential_revision: 1, 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_id: "host-trust-1".to_string(),
host_trust_revision: 1, host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly, access: workspace_api::RepositoryAccessMode::ReadOnly,
@@ -3933,7 +4042,6 @@ mod tests {
repository_source_fingerprint: "sha256:source".to_string(), repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: repository_resource_handle(), secret_resource: repository_resource_handle(),
private_key: SensitiveString::new("private-key-bytes"),
known_hosts_entry: SensitiveString::new("known-hosts-entry"), known_hosts_entry: SensitiveString::new("known-hosts-entry"),
}), }),
}), }),
@@ -3993,20 +4101,35 @@ mod tests {
runtime.bind_runtime_identity("runtime-1").unwrap(); runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle(); let handle = repository_resource_handle();
runtime runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient { .install_workspace_backend_resource_client(
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse { "workspace-1",
kind: crate::resource::BackendResourceKind::RepositorySshAccess, Arc::new(TestRepositoryResourceClient {
resource_id: handle.resource_id.clone(), response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
digest: handle.digest.clone(), kind: crate::resource::BackendResourceKind::RepositorySshAccess,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), resource_id: handle.resource_id.clone(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret { digest: handle.digest.clone(),
private_key: "private-key-bytes".to_string(), content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE
known_hosts_entry: "known-hosts-entry".to_string(), .to_string(),
}) bytes: serde_json::to_vec(&RepositorySshAccessSecret {
.unwrap(), credential_candidates: vec![
audit_correlation_id: handle.audit_correlation_id.clone(), 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(); .unwrap();
let request = WorkingDirectoryRepositoryAccessRequest { let request = WorkingDirectoryRepositoryAccessRequest {
working_directory_id: "working-directory-1".to_string(), working_directory_id: "working-directory-1".to_string(),
@@ -4016,10 +4139,19 @@ mod tests {
operation_id: "operation-1".to_string(), operation_id: "operation-1".to_string(),
config_revision: 1, config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess { ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(), credential_candidates: vec![
credential_revision: 1, 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_id: "host-trust-1".to_string(),
host_trust_revision: 1, host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly, access: workspace_api::RepositoryAccessMode::ReadOnly,
@@ -4028,7 +4160,6 @@ mod tests {
repository_source_fingerprint: "sha256:source".to_string(), repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle, secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(), known_hosts_entry: SensitiveString::default(),
}), }),
}, },
@@ -4049,7 +4180,23 @@ mod tests {
let accesses = backend.repository_accesses.lock().unwrap(); let accesses = backend.repository_accesses.lock().unwrap();
assert_eq!(accesses.len(), 1); assert_eq!(accesses.len(), 1);
let access = accesses[0].materialization.ssh.as_ref().unwrap(); 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"); assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry");
} }
@@ -4062,20 +4209,35 @@ mod tests {
runtime.bind_runtime_identity("runtime-1").unwrap(); runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle(); let handle = repository_resource_handle();
runtime runtime
.install_backend_resource_client(Arc::new(TestRepositoryResourceClient { .install_workspace_backend_resource_client(
response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse { "workspace-1",
kind: crate::resource::BackendResourceKind::RepositorySshAccess, Arc::new(TestRepositoryResourceClient {
resource_id: handle.resource_id.clone(), response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse {
digest: handle.digest.clone(), kind: crate::resource::BackendResourceKind::RepositorySshAccess,
content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), resource_id: handle.resource_id.clone(),
bytes: serde_json::to_vec(&RepositorySshAccessSecret { digest: handle.digest.clone(),
private_key: "create-private-key-bytes".to_string(), content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE
known_hosts_entry: "create-known-hosts-entry".to_string(), .to_string(),
}) bytes: serde_json::to_vec(&RepositorySshAccessSecret {
.unwrap(), credential_candidates: vec![
audit_correlation_id: handle.audit_correlation_id.clone(), 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(); .unwrap();
let request = WorkingDirectoryRequest { let request = WorkingDirectoryRequest {
repository: WorkingDirectoryRepository { repository: WorkingDirectoryRepository {
@@ -4089,7 +4251,7 @@ mod tests {
source_fingerprint: "sha256:source".to_string(), source_fingerprint: "sha256:source".to_string(),
selector: None, selector: None,
}, },
materializer: MaterializerKind::RuntimeGitCache, materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: Some("working-directory-1".to_string()), backend_workdir_id: Some("working-directory-1".to_string()),
materialization: Some(RepositoryMaterializationContext { materialization: Some(RepositoryMaterializationContext {
workspace_id: "workspace-1".to_string(), workspace_id: "workspace-1".to_string(),
@@ -4097,10 +4259,19 @@ mod tests {
operation_id: "operation-create".to_string(), operation_id: "operation-create".to_string(),
config_revision: 1, config_revision: 1,
config_projection_digest: "sha256:projection".to_string(), config_projection_digest: "sha256:projection".to_string(),
cache_generation: 0,
ssh: Some(RepositorySshMaterializationAccess { ssh: Some(RepositorySshMaterializationAccess {
credential_id: "credential-1".to_string(), credential_candidates: vec![
credential_revision: 1, 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_id: "host-trust-1".to_string(),
host_trust_revision: 1, host_trust_revision: 1,
access: workspace_api::RepositoryAccessMode::ReadOnly, access: workspace_api::RepositoryAccessMode::ReadOnly,
@@ -4109,7 +4280,6 @@ mod tests {
repository_source_fingerprint: "sha256:source".to_string(), repository_source_fingerprint: "sha256:source".to_string(),
repository_uri: "ssh://git@example.test/repo.git".to_string(), repository_uri: "ssh://git@example.test/repo.git".to_string(),
secret_resource: handle, secret_resource: handle,
private_key: SensitiveString::default(),
known_hosts_entry: SensitiveString::default(), known_hosts_entry: SensitiveString::default(),
}), }),
}), }),
@@ -4128,7 +4298,14 @@ mod tests {
.as_ref() .as_ref()
.and_then(|materialization| materialization.ssh.as_ref()) .and_then(|materialization| materialization.ssh.as_ref())
.unwrap(); .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!( assert_eq!(
access.known_hosts_entry.expose(), access.known_hosts_entry.expose(),
"create-known-hosts-entry" "create-known-hosts-entry"
@@ -4325,6 +4502,10 @@ mod tests {
.insert(request.worker_ref.worker_id.clone(), request.context); .insert(request.worker_ref.worker_id.clone(), request.context);
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), 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: request
.working_directory .working_directory
.as_ref() .as_ref()
@@ -4354,6 +4535,10 @@ mod tests {
.insert(request.worker_ref.worker_id.clone(), request.context); .insert(request.worker_ref.worker_id.clone(), request.context);
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), 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: request
.working_directory .working_directory
.as_ref() .as_ref()
@@ -5135,7 +5320,27 @@ mod tests {
let detail = runtime.create_worker(request).unwrap(); let detail = runtime.create_worker(request).unwrap();
assert_eq!(detail.status, WorkerStatus::Idle); 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] #[test]
@@ -5414,6 +5619,10 @@ mod tests {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), 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: request
.working_directory .working_directory
.as_ref() .as_ref()
@@ -5513,7 +5722,13 @@ mod tests {
assert_eq!(*backend.run_generations.lock().unwrap(), vec![1, 2]); assert_eq!(*backend.run_generations.lock().unwrap(), vec![1, 2]);
let restored = runtime.worker_detail(&detail.worker_ref).unwrap(); let restored = runtime.worker_detail(&detail.worker_ref).unwrap();
assert_eq!(restored.status, WorkerStatus::Idle); 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] #[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( workers.insert(
worker_ref.clone(), worker_ref.clone(),
RuntimeWorkerExecution { RuntimeWorkerExecution {
@@ -1560,6 +1564,7 @@ where
WorkerExecutionSpawnResult::Connected { WorkerExecutionSpawnResult::Connected {
handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()), handle: WorkerExecutionHandle::new(worker_ref, self.backend_id()),
worker_state: connected_worker_state,
working_directory: working_directory.map(|binding| binding.status()), working_directory: working_directory.map(|binding| binding.status()),
} }
} }
@@ -2243,7 +2248,7 @@ mod tests {
use crate::identity::WorkerRef; use crate::identity::WorkerRef;
use crate::management::RuntimeOptions; use crate::management::RuntimeOptions;
use crate::observation::WorkerObservationCursor; use crate::observation::WorkerObservationCursor;
use crate::working_directory::RuntimeGitCacheMaterializer; use crate::working_directory::RuntimeGitMaterializer;
use agen::Engine; use agen::Engine;
use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent}; use agen::llm_client::event::{Event as LlmEvent, ResponseStatus, StatusEvent};
use agen::llm_client::{ClientError, LlmClient, Request}; use agen::llm_client::{ClientError, LlmClient, Request};
@@ -2987,13 +2992,13 @@ mod tests {
source_fingerprint: "sha256:test".to_string(), source_fingerprint: "sha256:test".to_string(),
selector: Some(RepositorySelector::from("HEAD")), selector: Some(RepositorySelector::from("HEAD")),
}, },
materializer: MaterializerKind::RuntimeGitCache, materializer: MaterializerKind::RuntimeGitClone,
backend_workdir_id: None, backend_workdir_id: None,
materialization: None, materialization: None,
} }
} }
fn materialized_worktree_root( fn materialized_clone_root(
runtime_base: &std::path::Path, runtime_base: &std::path::Path,
working_directory_id: &str, working_directory_id: &str,
) -> PathBuf { ) -> PathBuf {
@@ -3729,9 +3734,7 @@ mod tests {
}; };
let backend = WorkerRuntimeExecutionBackend::new(factory) let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap() .unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new( .with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
runtime_base.path(),
));
let runtime = let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap(); .unwrap();
@@ -3968,9 +3971,7 @@ mod tests {
}; };
let backend = WorkerRuntimeExecutionBackend::new(factory) let backend = WorkerRuntimeExecutionBackend::new(factory)
.unwrap() .unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new( .with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
runtime_base.path(),
));
let runtime = let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap(); .unwrap();
@@ -3985,13 +3986,13 @@ mod tests {
.summary .summary
.working_directory_id .working_directory_id
.clone(); .clone();
let worktree_root = materialized_worktree_root(runtime_base.path(), &workdir_id); let clone_root = materialized_clone_root(runtime_base.path(), &workdir_id);
assert!(worktree_root.join("README.md").exists()); assert!(clone_root.join("README.md").exists());
runtime.stop_worker(&detail.worker_ref, None).unwrap(); runtime.stop_worker(&detail.worker_ref, None).unwrap();
runtime.delete_worker(&detail.worker_ref).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(); let status = runtime.working_directory(&workdir_id).unwrap();
assert_eq!( assert_eq!(
status.summary.status, status.summary.status,
@@ -4007,9 +4008,7 @@ mod tests {
let repo = create_clean_repo(); let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory) let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap() .unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new( .with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
runtime_base.path(),
));
let runtime = let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap(); .unwrap();
@@ -4018,8 +4017,8 @@ mod tests {
.create_working_directory(working_directory_request(repo.path())) .create_working_directory(working_directory_request(repo.path()))
.unwrap(); .unwrap();
let workdir_id = status.summary.working_directory_id.clone(); let workdir_id = status.summary.working_directory_id.clone();
let worktree_root = materialized_worktree_root(runtime_base.path(), &workdir_id); let clone_root = materialized_clone_root(runtime_base.path(), &workdir_id);
assert!(worktree_root.join("README.md").exists()); assert!(clone_root.join("README.md").exists());
let mut request = create_request("chat"); let mut request = create_request("chat");
request.working_directory = Some(WorkingDirectoryClaim { request.working_directory = Some(WorkingDirectoryClaim {
working_directory_id: workdir_id.clone(), working_directory_id: workdir_id.clone(),
@@ -4029,7 +4028,7 @@ mod tests {
let error = runtime.create_worker(request).unwrap_err(); let error = runtime.create_worker(request).unwrap_err();
assert!(format!("{error:?}").contains("spawn failed")); 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(); let status = runtime.working_directory(&workdir_id).unwrap();
assert_eq!( assert_eq!(
status.summary.status, status.summary.status,
@@ -4043,9 +4042,7 @@ mod tests {
let repo = create_clean_repo(); let repo = create_clean_repo();
let backend = WorkerRuntimeExecutionBackend::new(FailingFactory) let backend = WorkerRuntimeExecutionBackend::new(FailingFactory)
.unwrap() .unwrap()
.with_working_directory_materializer(RuntimeGitCacheMaterializer::new( .with_working_directory_materializer(RuntimeGitMaterializer::new(runtime_base.path()));
runtime_base.path(),
));
let runtime = let runtime =
EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend)) EmbeddedRuntime::with_execution_backend(RuntimeOptions::default(), Arc::new(backend))
.unwrap(); .unwrap();
@@ -4066,6 +4063,6 @@ mod tests {
}) })
.unwrap_or(0); .unwrap_or(0);
assert_eq!(remaining_workdirs, 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", "repository_key": "main",
"creation_selector": "refs/heads/main", "creation_selector": "refs/heads/main",
"creation_ref": "0123456789abcdef", "creation_ref": "0123456789abcdef",
"materializer_kind": "local_git_worktree", "materializer_kind": "runtime_git_clone",
"cleanup_target": { "cleanup_target": {
"kind": "git_worktree", "kind": "runtime_git_clone",
"working_directory_id": id, "working_directory_id": id,
"repository_key": "main" "repository_key": "main"
}, },
@@ -1242,16 +1242,10 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn scoped_broker_operations_carry_no_child_context() { async fn scoped_broker_operations_carry_no_child_context() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![ let client = Arc::new(RecordingWorkspaceClient::new(vec![response(json!({
response(json!({ "operation": "stat",
"operation": "stat", "result": {"path": "visible.txt", "kind": "file", "size": 8}
"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( let broker = workdir::WorkdirToolBroker::new(WorkspaceAttachedWorkdirSession::handle(
client.clone(), client.clone(),
)); ));
@@ -1275,7 +1269,7 @@ mod tests {
.unwrap(); .unwrap();
let requests = client.requests(); let requests = client.requests();
assert_eq!(requests.len(), 2); assert_eq!(requests.len(), 1);
for request in requests { for request in requests {
assert_eq!( assert_eq!(
request.path, request.path,
+279 -25
View File
@@ -607,6 +607,64 @@ pub struct WorkspaceMetadataMutationResponse {
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
/// Lifecycle state for a Workspace-scoped Ed25519 signing identity.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceSigningIdentityState {
PendingProvisioning,
Active,
}
/// Public metadata for a Workspace signing identity. Private material and its
/// storage reference are deliberately not part of this wire authority.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceSigningIdentityPublic {
pub workspace_id: String,
pub key_id: String,
pub algorithm: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub public_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub public_key_fingerprint: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
pub state: WorkspaceSigningIdentityState,
pub created_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub provisioned_at: Option<String>,
}
/// Copyable public trust bundle consumed by future Runtime enrollment work.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspacePublicIdentityBundle {
pub workspace_id: String,
pub backend_url: String,
pub key_id: String,
pub algorithm: String,
pub public_key: String,
pub public_key_fingerprint: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceSigningIdentityResponse {
pub identity: WorkspaceSigningIdentityPublic,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional))]
pub public_bundle: Option<WorkspacePublicIdentityBundle>,
}
pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES: usize = 128;
pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128; pub const WORKSPACE_DELETION_MAX_REVISION_BYTES: usize = 128;
pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256; pub const WORKSPACE_DELETION_MAX_CONFIRMATION_BYTES: usize = 256;
@@ -1094,6 +1152,58 @@ pub struct RepositoryDetailResponse {
pub source: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -1138,8 +1248,7 @@ pub struct Diagnostic {
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum WorkingDirectoryMaterializerKind { pub enum WorkingDirectoryMaterializerKind {
#[default] #[default]
RuntimeGitCache, RuntimeGitClone,
LocalGitWorktree,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -1522,6 +1631,71 @@ pub struct RuntimeSummary {
pub diagnostics: Vec<Diagnostic>, pub diagnostics: Vec<Diagnostic>,
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceRuntimeBindingState {
Configured,
Verified,
Revoked,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeConnectionDisplayState {
Configured,
Verified,
Unavailable,
Revoked,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum RuntimeVerificationOutcome {
Verified,
ChallengeIssued,
VerificationFailed,
ConnectivityFailed,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimeVerificationEvidenceSummary {
pub verified_at: Option<String>,
pub last_checked_at: String,
pub last_outcome: RuntimeVerificationOutcome,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub binding_revision: u64,
pub workspace_key_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub workspace_identity_revision: u64,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub workspace_trust_generation: u64,
pub runtime_public_key_fingerprint: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub runtime_identity_revision: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct WorkspaceRuntimeBindingSummary {
pub state: WorkspaceRuntimeBindingState,
pub connection_state: RuntimeConnectionDisplayState,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub revision: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub workspace_key_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub workspace_key_generation: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verification: Option<RuntimeVerificationEvidenceSummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -1531,6 +1705,8 @@ pub struct RuntimeManagementSummary {
pub removable: bool, pub removable: bool,
pub endpoint_configured: bool, pub endpoint_configured: bool,
pub token_ref_configured: bool, pub token_ref_configured: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub binding: Option<WorkspaceRuntimeBindingSummary>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1613,16 +1789,6 @@ pub struct RuntimeTrustKeyRevealResponse {
pub public_key: String, pub public_key: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PutRuntimeTrustKeyRequest {
pub public_key: String,
#[serde(default)]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_revision: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -1653,12 +1819,24 @@ pub struct RuntimeTrustConflictResponse {
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct RuntimePublicIdentityBundle {
pub identity_id: String,
pub public_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct CreateRemoteRuntimeRequest { pub struct CreateRemoteRuntimeRequest {
pub runtime_id: String, pub public_bundle: RuntimePublicIdentityBundle,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>, pub display_name: Option<String>,
pub endpoint: String, pub endpoint: String,
pub token_ref: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(type = "number | null"))]
pub expected_revision: Option<u64>,
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -1690,6 +1868,10 @@ pub enum RuntimeConnectionTestFailureKind {
pub struct RuntimeConnectionTestResponse { pub struct RuntimeConnectionTestResponse {
pub workspace_id: String, pub workspace_id: String,
pub runtime_id: String, pub runtime_id: String,
#[cfg_attr(feature = "typescript", ts(type = "number"))]
pub binding_revision: u64,
pub connection_state: RuntimeConnectionDisplayState,
pub verification: Option<RuntimeVerificationEvidenceSummary>,
pub checked_at: String, pub checked_at: String,
pub status: RuntimeConnectionTestStatus, pub status: RuntimeConnectionTestStatus,
pub failure_kind: Option<RuntimeConnectionTestFailureKind>, pub failure_kind: Option<RuntimeConnectionTestFailureKind>,
@@ -2324,6 +2506,27 @@ pub struct CreateRepositorySshCredentialRequest {
pub passphrase: Option<String>, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -2848,6 +3051,10 @@ pub fn catalog_typescript() -> String {
WorkspaceMetadataSettingsResponse::decl(&config), WorkspaceMetadataSettingsResponse::decl(&config),
UpdateWorkspaceMetadataRequest::decl(&config), UpdateWorkspaceMetadataRequest::decl(&config),
WorkspaceMetadataMutationResponse::decl(&config), WorkspaceMetadataMutationResponse::decl(&config),
WorkspaceSigningIdentityState::decl(&config),
WorkspaceSigningIdentityPublic::decl(&config),
WorkspacePublicIdentityBundle::decl(&config),
WorkspaceSigningIdentityResponse::decl(&config),
ProfileSettingsResponse::decl(&config), ProfileSettingsResponse::decl(&config),
WorkspaceProfileSummary::decl(&config), WorkspaceProfileSummary::decl(&config),
WorkspaceProfileSourceSummary::decl(&config), WorkspaceProfileSourceSummary::decl(&config),
@@ -2862,12 +3069,22 @@ pub fn catalog_typescript() -> String {
GitCommitSummary::decl(&config), GitCommitSummary::decl(&config),
RepositoryListResponse::decl(&config), RepositoryListResponse::decl(&config),
RepositoryDetailResponse::decl(&config), RepositoryDetailResponse::decl(&config),
RepositorySshConnectionProbeRequest::decl(&config),
RepositorySshHostKeyCandidate::decl(&config),
RepositorySshConnectionTrustState::decl(&config),
RepositorySshConnectionProbeResponse::decl(&config),
ConfirmRepositorySshHostTrustRequest::decl(&config),
RepositoryLogResponse::decl(&config), RepositoryLogResponse::decl(&config),
RuntimeSourceKind::decl(&config), RuntimeSourceKind::decl(&config),
RuntimeSourceStatus::decl(&config), RuntimeSourceStatus::decl(&config),
RuntimeIdentityAuthority::decl(&config), RuntimeIdentityAuthority::decl(&config),
RuntimeSourceSummary::decl(&config), RuntimeSourceSummary::decl(&config),
RuntimeSummary::decl(&config), RuntimeSummary::decl(&config),
WorkspaceRuntimeBindingState::decl(&config),
RuntimeConnectionDisplayState::decl(&config),
RuntimeVerificationOutcome::decl(&config),
RuntimeVerificationEvidenceSummary::decl(&config),
WorkspaceRuntimeBindingSummary::decl(&config),
RuntimeManagementSummary::decl(&config), RuntimeManagementSummary::decl(&config),
WorkspaceRuntimeResource::decl(&config), WorkspaceRuntimeResource::decl(&config),
RuntimeTrustKeyStatus::decl(&config), RuntimeTrustKeyStatus::decl(&config),
@@ -2876,10 +3093,11 @@ pub fn catalog_typescript() -> String {
RuntimeTrustAuditEntry::decl(&config), RuntimeTrustAuditEntry::decl(&config),
WorkspaceRuntimeDetail::decl(&config), WorkspaceRuntimeDetail::decl(&config),
RuntimeTrustKeyRevealResponse::decl(&config), RuntimeTrustKeyRevealResponse::decl(&config),
PutRuntimeTrustKeyRequest::decl(&config),
RevokeRuntimeTrustKeyRequest::decl(&config), RevokeRuntimeTrustKeyRequest::decl(&config),
RuntimeTrustConflictKind::decl(&config), RuntimeTrustConflictKind::decl(&config),
RuntimeTrustConflictResponse::decl(&config), RuntimeTrustConflictResponse::decl(&config),
RuntimePublicIdentityBundle::decl(&config),
CreateRemoteRuntimeRequest::decl(&config),
RuntimeConnectionTestStatus::decl(&config), RuntimeConnectionTestStatus::decl(&config),
RuntimeConnectionTestFailureKind::decl(&config), RuntimeConnectionTestFailureKind::decl(&config),
RuntimeConnectionTestResponse::decl(&config), RuntimeConnectionTestResponse::decl(&config),
@@ -2900,6 +3118,8 @@ pub fn repository_access_api_typescript() -> String {
let declarations = [ let declarations = [
RepositorySshCredential::decl(&config), RepositorySshCredential::decl(&config),
CreateRepositorySshCredentialRequest::decl(&config), CreateRepositorySshCredentialRequest::decl(&config),
GenerateRepositorySshCredentialRequest::decl(&config),
RepositorySshPublicKey::decl(&config),
RotateRepositorySshCredentialRequest::decl(&config), RotateRepositorySshCredentialRequest::decl(&config),
DeleteRepositorySshCredentialRequest::decl(&config), DeleteRepositorySshCredentialRequest::decl(&config),
RepositorySshHostTrust::decl(&config), RepositorySshHostTrust::decl(&config),
@@ -3707,14 +3927,6 @@ mod tests {
})) }))
.is_err() .is_err()
); );
assert!(
serde_json::from_value::<PutRuntimeTrustKeyRequest>(serde_json::json!({
"public_key": "key",
"expected_revision": 1,
"replace": true
}))
.is_err()
);
assert!( assert!(
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({ serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
"expected_revision": 1, "expected_revision": 1,
@@ -3729,6 +3941,9 @@ mod tests {
let compatible = serde_json::json!({ let compatible = serde_json::json!({
"workspace_id": "workspace-test", "workspace_id": "workspace-test",
"runtime_id": "runtime-test", "runtime_id": "runtime-test",
"binding_revision": 3,
"connection_state": "verified",
"verification": null,
"checked_at": "2026-09-01T12:00:00Z", "checked_at": "2026-09-01T12:00:00Z",
"status": "compatible", "status": "compatible",
"failure_kind": null, "failure_kind": null,
@@ -3877,6 +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 { fn companion_worker() -> WorkspaceWorkerDiscoveryItem {
WorkspaceWorkerDiscoveryItem { WorkspaceWorkerDiscoveryItem {
subject: WorkspaceWorkerSubject::RuntimeWorker { subject: WorkspaceWorkerSubject::RuntimeWorker {
@@ -4302,7 +4556,7 @@ mod tests {
current_ref: None, current_ref: None,
current_tree: None, current_tree: None,
observed_at_epoch_seconds: None, observed_at_epoch_seconds: None,
materializer_kind: WorkingDirectoryMaterializerKind::RuntimeGitCache, materializer_kind: WorkingDirectoryMaterializerKind::RuntimeGitClone,
cleanup_target: None, cleanup_target: None,
status: WorkingDirectoryStatusKind::Active, status: WorkingDirectoryStatusKind::Active,
cleanliness: None, cleanliness: None,
@@ -4339,7 +4593,7 @@ mod tests {
"items": [{ "items": [{
"working_directory_id": "workdir-1", "working_directory_id": "workdir-1",
"repository_key": "main", "repository_key": "main",
"materializer_kind": "runtime_git_cache", "materializer_kind": "runtime_git_clone",
"status": "active", "status": "active",
"occupied_by": { "occupied_by": {
"runtime_worker_id": "worker-1", "runtime_worker_id": "worker-1",
+1 -1
View File
@@ -38,7 +38,7 @@ memory.workspace = true
merge-request.workspace = true merge-request.workspace = true
tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] } tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] }
tower.workspace = true tower.workspace = true
tokio-tungstenite.workspace = true tokio-tungstenite = { workspace = true, features = ["rustls-tls-webpki-roots"] }
worker.workspace = true worker.workspace = true
workspace-api.workspace = true workspace-api.workspace = true
workdir = { workspace = true, features = ["http-client"] } workdir = { workspace = true, features = ["http-client"] }
File diff suppressed because it is too large Load Diff
+83 -2
View File
@@ -441,13 +441,49 @@ CREATE TABLE workspace_runtime_bindings (
public_key TEXT NOT NULL, public_key TEXT NOT NULL,
public_key_fingerprint TEXT NOT NULL, public_key_fingerprint TEXT NOT NULL,
binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0), binding_revision INTEGER NOT NULL DEFAULT 1 CHECK (binding_revision > 0),
state TEXT NOT NULL CHECK (state IN ('configured', 'verified', 'revoked')),
authentication_mode TEXT NOT NULL CHECK (authentication_mode IN ('legacy_server_issuer', 'workspace_identity')),
workspace_key_id TEXT,
workspace_key_generation INTEGER CHECK (workspace_key_generation > 0),
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
revoked_at TEXT, revoked_at TEXT,
PRIMARY KEY (workspace_id, runtime_id), PRIMARY KEY (workspace_id, runtime_id),
UNIQUE (workspace_id, public_key_fingerprint), UNIQUE (workspace_id, public_key_fingerprint),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE RESTRICT,
CHECK (
(authentication_mode = 'legacy_server_issuer' AND workspace_key_id IS NULL AND workspace_key_generation IS NULL)
OR
(authentication_mode = 'workspace_identity' AND workspace_key_id IS NOT NULL AND workspace_key_generation IS NOT NULL)
),
CHECK (
(state = 'revoked' AND revoked_at IS NOT NULL)
OR
(state != 'revoked' AND revoked_at IS NULL)
)
); );
CREATE TABLE workspace_runtime_verifications (
workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL,
binding_revision INTEGER NOT NULL CHECK(binding_revision > 0),
workspace_key_id TEXT NOT NULL,
workspace_identity_revision INTEGER NOT NULL CHECK(workspace_identity_revision > 0),
workspace_trust_generation INTEGER NOT NULL CHECK(workspace_trust_generation > 0),
runtime_public_key_fingerprint TEXT NOT NULL,
runtime_identity_revision INTEGER NOT NULL CHECK(runtime_identity_revision > 0),
challenge_id TEXT NOT NULL,
state TEXT NOT NULL CHECK(state IN ('pending', 'verified', 'failed')),
last_outcome TEXT NOT NULL,
verified_at TEXT,
checked_at TEXT NOT NULL,
PRIMARY KEY(workspace_id, runtime_id),
FOREIGN KEY(workspace_id, runtime_id)
REFERENCES workspace_runtime_bindings(workspace_id, runtime_id) ON DELETE CASCADE,
CHECK((state = 'verified' AND verified_at IS NOT NULL)
OR (state != 'verified' AND verified_at IS NULL))
);
CREATE INDEX workspace_runtime_verifications_state_idx
ON workspace_runtime_verifications(workspace_id, state, checked_at DESC);
CREATE TABLE workspace_runtime_binding_audit ( CREATE TABLE workspace_runtime_binding_audit (
workspace_id TEXT NOT NULL, workspace_id TEXT NOT NULL,
runtime_id TEXT NOT NULL, runtime_id TEXT NOT NULL,
@@ -588,7 +624,7 @@ CREATE TABLE workdir_create_operations (
state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')), state TEXT NOT NULL CHECK (state IN ('pending', 'succeeded', 'failed')),
failure TEXT, failure TEXT,
created_at TEXT NOT NULL, 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), PRIMARY KEY (workspace_id, operation_id),
UNIQUE (workspace_id, working_directory_id) UNIQUE (workspace_id, working_directory_id)
); );
@@ -795,6 +831,51 @@ CREATE TABLE workspace_create_operations (
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
); );
CREATE TABLE workspace_signing_identities (
workspace_id TEXT PRIMARY KEY,
key_id TEXT NOT NULL UNIQUE,
algorithm TEXT NOT NULL CHECK (algorithm = 'ed25519'),
public_key TEXT,
public_key_fingerprint TEXT,
private_material_ref TEXT NOT NULL UNIQUE,
revision INTEGER NOT NULL CHECK (revision >= 1),
state TEXT NOT NULL CHECK (state IN ('pending_provisioning', 'active')),
created_at TEXT NOT NULL,
provisioned_at TEXT,
updated_at TEXT NOT NULL,
CHECK (
(state = 'pending_provisioning' AND public_key IS NULL AND public_key_fingerprint IS NULL AND provisioned_at IS NULL)
OR
(state = 'active' AND public_key IS NOT NULL AND public_key_fingerprint IS NOT NULL AND provisioned_at IS NOT NULL)
),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE TABLE workspace_signing_identity_provisioning_operations (
operation_key TEXT PRIMARY KEY,
request_fingerprint TEXT NOT NULL,
operation_kind TEXT NOT NULL CHECK (operation_kind IN ('workspace_create', 'existing_workspace')),
workspace_id TEXT NOT NULL UNIQUE,
key_id TEXT NOT NULL UNIQUE,
private_material_ref TEXT NOT NULL UNIQUE,
revision INTEGER NOT NULL CHECK (revision >= 1),
actor_account_id TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending', 'completed')),
created_at TEXT NOT NULL,
completed_at TEXT
);
CREATE TABLE workspace_signing_identity_audit (
event_id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL,
key_id TEXT NOT NULL,
action TEXT NOT NULL CHECK (action IN ('provisioned')),
revision INTEGER NOT NULL CHECK (revision >= 1),
public_key_fingerprint TEXT NOT NULL,
actor_account_id TEXT NOT NULL,
created_at TEXT NOT NULL,
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE
);
CREATE INDEX workspace_signing_identity_audit_workspace_idx
ON workspace_signing_identity_audit(workspace_id, created_at DESC);
CREATE TABLE workspace_memory_documents ( CREATE TABLE workspace_memory_documents (
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE, workspace_id TEXT PRIMARY KEY REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
body_md TEXT NOT NULL, body_md TEXT NOT NULL,
+3
View File
@@ -34,6 +34,7 @@ mod workdir_removal;
pub mod worker_source; pub mod worker_source;
pub mod workspace_catalog; pub mod workspace_catalog;
mod workspace_deletion; mod workspace_deletion;
pub mod workspace_signing_identity;
mod workspace_subscription; mod workspace_subscription;
pub use authority::{ pub use authority::{
@@ -138,6 +139,8 @@ pub enum Error {
WorkerSourceIdentity(String), WorkerSourceIdentity(String),
#[error("workspace identity error: {0}")] #[error("workspace identity error: {0}")]
WorkspaceIdentity(String), WorkspaceIdentity(String),
#[error("Workspace signing identity error ({code}): {message}")]
WorkspaceSigningIdentity { code: String, message: String },
#[error("store error: {0}")] #[error("store error: {0}")]
Store(String), Store(String),
} }
+98 -445
View File
@@ -1,15 +1,17 @@
use std::collections::VecDeque;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::path::{Path, PathBuf}; use std::path::PathBuf;
use std::process::ExitCode; use std::process::ExitCode;
use std::sync::Arc; use std::sync::Arc;
use chrono::Utc; use chrono::Utc;
use serde::{Deserialize, Serialize};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key}; use yoi_workspace_server::hosts::{
use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig}; EMBEDDED_RUNTIME_ID, RemoteRuntimeConfig, is_loopback_runtime_origin,
use yoi_workspace_server::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding}; };
use yoi_workspace_server::store::{
SqliteWorkspaceStore, WorkspaceRuntimeAuthenticationMode, WorkspaceRuntimeBinding,
WorkspaceRuntimeBindingState,
};
use yoi_workspace_server::{ use yoi_workspace_server::{
ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile, ControlPlaneStore, ResolvedWorkspaceBackendConfig, ServerConfig, ServerHostConfigFile,
WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog,
@@ -18,8 +20,6 @@ use yoi_workspace_server::{
#[derive(Debug)] #[derive(Debug)]
enum Command { enum Command {
Serve(ServeOptions), Serve(ServeOptions),
Identity(Vec<String>),
TrustRuntime(Vec<String>),
Migrate(MigrateOptions), Migrate(MigrateOptions),
Skills(SkillsCommand), Skills(SkillsCommand),
Help, Help,
@@ -76,8 +76,6 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
let args = std::env::args().skip(1).collect::<Vec<_>>(); let args = std::env::args().skip(1).collect::<Vec<_>>();
match parse_command(&args)? { match parse_command(&args)? {
Command::Serve(options) => run_serve(options).await, Command::Serve(options) => run_serve(options).await,
Command::Identity(args) => run_identity_command(args),
Command::TrustRuntime(args) => run_trust_runtime_command(args),
Command::Migrate(options) => run_migrate(options), Command::Migrate(options) => run_migrate(options),
Command::Skills(command) => run_skills(command), Command::Skills(command) => run_skills(command),
Command::Help => Ok(()), Command::Help => Ok(()),
@@ -91,8 +89,6 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
}; };
match command.as_str() { match command.as_str() {
"identity" => Ok(Command::Identity(rest.to_vec())),
"trust-runtime" => Ok(Command::TrustRuntime(rest.to_vec())),
"migrate" => parse_migrate_options(rest).map(Command::Migrate), "migrate" => parse_migrate_options(rest).map(Command::Migrate),
"skills" => parse_skills_command(rest), "skills" => parse_skills_command(rest),
"serve" => { "serve" => {
@@ -107,371 +103,11 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
Ok(Command::Help) Ok(Command::Help)
} }
other => Err(CliError(format!( other => Err(CliError(format!(
"unknown command `{other}`; expected `identity`, `trust-runtime`, `migrate`, `skills`, or `serve`" "unknown command `{other}`; expected `migrate`, `skills`, or `serve`"
))), ))),
} }
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct ServerIdentityFile {
identity: RuntimeIdentityMaterial,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
struct PublicIdentityView {
identity_id: String,
public_key: String,
}
fn server_identity_path() -> PathBuf {
ServerConfig::default_server_data_root().join("identity.toml")
}
fn read_server_identity_file(
path: &Path,
) -> Result<Option<ServerIdentityFile>, Box<dyn std::error::Error>> {
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)?;
Ok(Some(toml::from_str(&contents)?))
}
fn write_server_identity_file(
path: &Path,
identity: &ServerIdentityFile,
) -> Result<(), Box<dyn std::error::Error>> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(identity)?;
write_secret_file(path, contents.as_bytes())?;
Ok(())
}
fn write_secret_file(path: &Path, contents: &[u8]) -> std::io::Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)?;
use std::io::Write as _;
file.write_all(contents)?;
}
#[cfg(not(unix))]
{
std::fs::write(path, contents)?;
}
Ok(())
}
fn public_identity_view(identity: &RuntimeIdentityMaterial) -> PublicIdentityView {
PublicIdentityView {
identity_id: identity.identity_id.clone(),
public_key: identity.public_key.clone(),
}
}
fn run_identity_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut args = VecDeque::from(args);
let subcommand = args
.pop_front()
.ok_or_else(|| CliError("identity requires `init` or `show`".to_string()))?;
match subcommand.as_str() {
"init" => {
let mut server_id = None;
let mut replace = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--server-id" => server_id = Some(take_value(&flag, inline_value, &mut args)?),
"--replace" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
replace = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown identity init argument `{flag}`"
))));
}
}
}
let server_id = server_id
.ok_or_else(|| CliError("identity init requires --server-id".to_string()))?;
let path = server_identity_path();
if read_server_identity_file(&path)?.is_some() && !replace {
return Err(Box::new(CliError(format!(
"server identity already exists at {}; pass --replace to rotate it",
path.display()
))));
}
let identity = RuntimeIdentityMaterial::generate(server_id)?;
write_server_identity_file(
&path,
&ServerIdentityFile {
identity: identity.clone(),
},
)?;
println!("server_id={}", identity.identity_id);
println!("public_key={}", identity.public_key);
println!("identity_file={}", path.display());
Ok(())
}
"show" => {
let mut json = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--json" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
json = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown identity show argument `{flag}`"
))));
}
}
}
let path = server_identity_path();
let identity = read_server_identity_file(&path)?.ok_or_else(|| {
CliError(format!(
"server identity is not initialized at {}",
path.display()
))
})?;
let view = public_identity_view(&identity.identity);
if json {
println!("{}", serde_json::to_string_pretty(&view)?);
} else {
println!("server_id={}", view.identity_id);
println!("public_key={}", view.public_key);
println!("identity_file={}", path.display());
}
Ok(())
}
_ => Err(Box::new(CliError(format!(
"unknown identity subcommand `{subcommand}`"
)))),
}
}
fn run_trust_runtime_command(args: Vec<String>) -> Result<(), Box<dyn std::error::Error>> {
let mut args = VecDeque::from(args);
let subcommand = args
.pop_front()
.ok_or_else(|| CliError("trust-runtime requires `add`, `list`, or `revoke`".to_string()))?;
let database_path = ServerConfig::default_server_database_path();
if let Some(parent) = database_path.parent() {
std::fs::create_dir_all(parent)?;
}
let store = SqliteWorkspaceStore::open(&database_path)?;
match subcommand.as_str() {
"add" => {
let mut runtime_id = None;
let mut workspace_id = None;
let mut base_url = None;
let mut public_key = None;
let mut display_name = None;
let mut replace = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--runtime-id" => {
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--base-url" | "--endpoint" => {
base_url = Some(take_value(&flag, inline_value, &mut args)?)
}
"--public-key" => {
public_key = Some(take_value(&flag, inline_value, &mut args)?)
}
"--display-name" => {
display_name = Some(take_value(&flag, inline_value, &mut args)?)
}
"--replace" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
replace = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime add argument `{flag}`"
))));
}
}
}
let runtime_id = runtime_id
.ok_or_else(|| CliError("trust-runtime add requires --runtime-id".to_string()))?;
let workspace_id = workspace_id
.ok_or_else(|| CliError("trust-runtime add requires --workspace-id".to_string()))?;
if !store
.list_workspaces()?
.iter()
.any(|workspace| workspace.workspace_id == workspace_id)
{
return Err(Box::new(CliError(format!(
"Workspace `{workspace_id}` is not registered"
))));
}
let base_url = base_url
.ok_or_else(|| CliError("trust-runtime add requires --base-url".to_string()))?;
let public_key = public_key
.ok_or_else(|| CliError("trust-runtime add requires --public-key".to_string()))?;
decode_public_key(&public_key)?;
let now = Utc::now().to_rfc3339();
let outcome = store.upsert_workspace_runtime_binding(
WorkspaceRuntimeBinding {
workspace_id: workspace_id.clone(),
runtime_id: runtime_id.clone(),
display_name: display_name.unwrap_or_else(|| runtime_id.clone()),
base_url,
public_key,
public_key_fingerprint: String::new(),
binding_revision: 1,
created_at: now.clone(),
updated_at: now,
revoked_at: None,
},
replace,
)?;
println!("workspace_id={workspace_id}");
println!("runtime_id={runtime_id}");
println!(
"result={}",
match outcome {
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Created =>
"created",
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged =>
"unchanged",
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Replaced =>
"replaced",
}
);
println!("server_db={}", database_path.display());
Ok(())
}
"list" => {
let mut workspace_id = None;
let mut json = false;
let mut include_revoked = false;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--json" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
json = true;
}
"--include-revoked" => {
ensure_no_inline_value(&flag, inline_value.as_deref())?;
include_revoked = true;
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime list argument `{flag}`"
))));
}
}
}
let workspace_id = workspace_id.ok_or_else(|| {
CliError("trust-runtime list requires --workspace-id".to_string())
})?;
let records = store.list_workspace_runtime_bindings(&workspace_id, include_revoked)?;
if json {
println!("{}", serde_json::to_string_pretty(&records)?);
} else {
for runtime in records {
println!(
"workspace_id={} runtime_id={} base_url={} public_key_fingerprint={} revoked_at={}",
runtime.workspace_id,
runtime.runtime_id,
runtime.base_url,
runtime.public_key_fingerprint,
runtime.revoked_at.unwrap_or_default()
);
}
}
Ok(())
}
"revoke" => {
let mut workspace_id = None;
let mut runtime_id = None;
while let Some(arg) = args.pop_front() {
let (flag, inline_value) = split_flag_value(arg)?;
match flag.as_str() {
"--workspace-id" => {
workspace_id = Some(take_value(&flag, inline_value, &mut args)?)
}
"--runtime-id" => {
runtime_id = Some(take_value(&flag, inline_value, &mut args)?)
}
_ => {
return Err(Box::new(CliError(format!(
"unknown trust-runtime revoke argument `{flag}`"
))));
}
}
}
let workspace_id = workspace_id.ok_or_else(|| {
CliError("trust-runtime revoke requires --workspace-id".to_string())
})?;
let runtime_id = runtime_id.ok_or_else(|| {
CliError("trust-runtime revoke requires --runtime-id".to_string())
})?;
let now = Utc::now().to_rfc3339();
if !store.revoke_workspace_runtime_binding(&workspace_id, &runtime_id, &now)? {
return Err(Box::new(CliError(format!(
"trusted runtime `{runtime_id}` is not registered or is already revoked"
))));
}
println!("revoked_runtime_id={runtime_id}");
Ok(())
}
_ => Err(Box::new(CliError(format!(
"unknown trust-runtime subcommand `{subcommand}`"
)))),
}
}
fn split_flag_value(arg: String) -> Result<(String, Option<String>), CliError> {
if let Some((flag, value)) = arg.split_once('=') {
if flag.is_empty() {
return Err(CliError("empty flag name".to_string()));
}
Ok((flag.to_string(), Some(value.to_string())))
} else {
Ok((arg, None))
}
}
fn take_value(
flag: &str,
inline_value: Option<String>,
args: &mut VecDeque<String>,
) -> Result<String, CliError> {
if let Some(value) = inline_value {
return Ok(value);
}
args.pop_front()
.ok_or_else(|| CliError(format!("{flag} requires a value")))
}
fn ensure_no_inline_value(flag: &str, inline_value: Option<&str>) -> Result<(), CliError> {
if inline_value.is_some() {
return Err(CliError(format!("{flag} does not accept a value")));
}
Ok(())
}
fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> { fn run_skills(command: SkillsCommand) -> Result<(), Box<dyn std::error::Error>> {
match command { match command {
SkillsCommand::List(options) => { SkillsCommand::List(options) => {
@@ -519,6 +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>> { fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>> {
if options.help { if options.help {
print_migrate_help(); print_migrate_help();
@@ -639,6 +300,7 @@ fn append_workspace_runtime_sources(
.into_iter() .into_iter()
.filter(|binding| { .filter(|binding| {
binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID
&& binding.state == WorkspaceRuntimeBindingState::Verified
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
@@ -647,30 +309,13 @@ fn append_workspace_runtime_sources(
.into_iter() .into_iter()
.flatten() .flatten()
.collect::<Vec<_>>(); .collect::<Vec<_>>();
let Some(server_identity) = read_server_identity_file(&server_identity_path())? else { for binding in bindings {
if !bindings.is_empty() { let Some(remote) = remote_runtime_config_from_binding(binding)? else {
return Err(Box::new(CliError( continue;
"Runtime bindings are registered but server identity is not initialized; run `yoi-server identity init`".to_string(),
)));
}
return Ok(());
};
for runtime in bindings {
let auth = RemoteRuntimeAuthConfig {
server_id: server_identity.identity.identity_id.clone(),
server_private_key: server_identity.identity.private_key.clone(),
}; };
let remote = RemoteRuntimeConfig::new(
runtime.runtime_id.clone(),
runtime.display_name,
runtime.base_url,
None,
)
.with_workspace_id(runtime.workspace_id.clone())
.with_auth(auth);
remote_runtime_sources.retain(|existing| { remote_runtime_sources.retain(|existing| {
existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str()) existing.workspace_id.as_deref() != remote.workspace_id.as_deref()
|| existing.runtime_id != runtime.runtime_id || existing.runtime_id != remote.runtime_id
}); });
remote_runtime_sources.push(remote); remote_runtime_sources.push(remote);
} }
@@ -840,7 +485,7 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
fn print_help() { fn print_help() {
println!( println!(
"yoi-server\n\nUsage:\n yoi-server identity init --server-id <SERVER_ID> [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id <RUNTIME_ID> --workspace-id <WORKSPACE_ID> --base-url <URL> --public-key <KEY> [--display-name <NAME>] [--replace]\n yoi-server trust-runtime list --workspace-id <WORKSPACE_ID> [--json] [--include-revoked]\n yoi-server trust-runtime revoke --workspace-id <WORKSPACE_ID> --runtime-id <RUNTIME_ID>\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" "yoi-server\n\nUsage:\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
); );
} }
@@ -952,60 +597,68 @@ mod tests {
"unknown serve option `--frontend=/tmp/web`" "unknown serve option `--frontend=/tmp/web`"
); );
} }
#[test] #[test]
fn server_identity_init_requires_explicit_server_id() { fn runtime_startup_rejects_legacy_server_issuer_bindings() {
let error = run_identity_command(vec!["init".to_string()]).unwrap_err();
assert_eq!(error.to_string(), "identity init requires --server-id");
}
#[test]
fn runtime_binding_requires_explicit_replace_for_changed_authority() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
let store = SqliteWorkspaceStore::open(&path).unwrap();
rusqlite::Connection::open(&path)
.unwrap()
.execute_batch(
"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
VALUES ('owner', 'user', 'owner', 'Owner', '1', '1');
INSERT INTO workspaces(workspace_id, owner_account_id, display_name, state, created_at, updated_at)
VALUES ('workspace-a', 'owner', 'Workspace A', 'active', '1', '1');",
)
.unwrap();
let public_key = RuntimeIdentityMaterial::generate("runtime-a")
.unwrap()
.public_key;
let binding = WorkspaceRuntimeBinding { let binding = WorkspaceRuntimeBinding {
workspace_id: "workspace-a".to_string(), workspace_id: "workspace-a".to_owned(),
runtime_id: "runtime-a".to_string(), runtime_id: "runtime-a".to_owned(),
display_name: "Runtime A".to_string(), display_name: "Runtime A".to_owned(),
base_url: "http://127.0.0.1:18080".to_string(), base_url: "https://runtime.example.test".to_owned(),
public_key, public_key: "unused".to_owned(),
public_key_fingerprint: String::new(), public_key_fingerprint: "unused".to_owned(),
binding_revision: 1, binding_revision: 1,
created_at: "2026-07-26T00:00:00Z".to_string(), state: WorkspaceRuntimeBindingState::Verified,
updated_at: "2026-07-26T00:00:00Z".to_string(), authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer,
workspace_key_id: None,
workspace_key_generation: None,
created_at: "2026-09-01T00:00:00Z".to_owned(),
updated_at: "2026-09-01T00:00:00Z".to_owned(),
revoked_at: None, revoked_at: None,
}; };
store let error = remote_runtime_config_from_binding(binding)
.upsert_workspace_runtime_binding(binding.clone(), false) .unwrap_err()
.unwrap(); .to_string();
assert!(matches!( assert_eq!(
store error,
.upsert_workspace_runtime_binding(binding.clone(), false) "Runtime binding 'workspace-a:runtime-a' still uses removed legacy Server-issued authentication"
.unwrap(),
yoi_workspace_server::store::WorkspaceRuntimeBindingUpsert::Unchanged
));
let mut changed = binding;
changed.base_url = "http://127.0.0.1:18081".to_string();
assert!(
store
.upsert_workspace_runtime_binding(changed.clone(), false)
.is_err()
); );
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`")
);
}
} }
} }
+460 -19
View File
@@ -7,16 +7,19 @@ use std::sync::Arc;
use chrono::{SecondsFormat, Utc}; use chrono::{SecondsFormat, Utc};
use config_source::ConfigSchemaContribution; use config_source::ConfigSchemaContribution;
use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey}; use ring::aead::{AES_256_GCM, Aad, LessSafeKey, Nonce, UnboundKey};
use ring::hmac;
use ring::rand::{SecureRandom, SystemRandom}; use ring::rand::{SecureRandom, SystemRandom};
use rusqlite::{OptionalExtension, TransactionBehavior, params}; use rusqlite::{OptionalExtension, TransactionBehavior, params};
use serde::Deserialize; use serde::Deserialize;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use ssh_key::private::Ed25519Keypair;
use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey}; use ssh_key::{Algorithm, HashAlg, LineEnding, PrivateKey, PublicKey};
use workspace_api::{ use workspace_api::{
CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest, CreateRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest, PutRepositorySshHostTrustRequest, RepositoryAccessMode, DeleteRepositorySshHostTrustRequest, GenerateRepositorySshCredentialRequest,
RepositoryAccessProjection, RepositorySshAccessBinding, RepositorySshCredential, PutRepositorySshHostTrustRequest, RepositoryAccessMode, RepositoryAccessProjection,
RepositorySshHostTrust, RotateRepositorySshCredentialRequest, RepositorySshAccessBinding, RepositorySshCredential, RepositorySshHostTrust,
RepositorySshPublicKey, RotateRepositorySshCredentialRequest,
}; };
use crate::config_source::{ use crate::config_source::{
@@ -42,6 +45,9 @@ const MAX_NAME_BYTES: usize = 200;
const MAX_IDENTIFIER_BYTES: usize = 128; const MAX_IDENTIFIER_BYTES: usize = 128;
const MASTER_KEY_BYTES: usize = 32; const MASTER_KEY_BYTES: usize = 32;
const NONCE_BYTES: usize = 12; 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)] #[derive(Debug, Default)]
pub struct RepositoryAccessConfigSchemaProvider; 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( fn project_repository_access_evaluation(
store: &dyn ControlPlaneStore, store: &dyn ControlPlaneStore,
secrets: &RepositorySecretService, secrets: &RepositorySecretService,
@@ -145,6 +188,9 @@ fn project_repository_access_evaluation(
.map_err(|error| { .map_err(|error| {
Error::InvalidInput(format!("invalid Repository access config: {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()); let mut bindings = Vec::with_capacity(config.repository_access.len());
for (repository_key, access) in config.repository_access { for (repository_key, access) in config.repository_access {
workspace_api::validate_repository_key(&repository_key) workspace_api::validate_repository_key(&repository_key)
@@ -181,22 +227,14 @@ fn project_repository_access_evaluation(
access.ssh.host_trust access.ssh.host_trust
)) ))
})?; })?;
let uri = url::Url::parse(&repository.source.uri).map_err(|_| { let (hostname, port) =
Error::InvalidInput(format!( repository_ssh_endpoint(repository_key.as_str(), &repository.source.uri)?.ok_or_else(
"Repository `{repository_key}` has an invalid SSH URI" || {
)) Error::InvalidInput(format!(
})?; "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 { if hostname != host_trust.hostname || port != host_trust.port {
return Err(Error::InvalidInput(format!( return Err(Error::InvalidInput(format!(
"Repository `{repository_key}` SSH host does not match host trust `{}`", "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( pub fn create_credential(
&self, &self,
workspace_id: &str, workspace_id: &str,
@@ -384,6 +568,11 @@ impl RepositorySecretService {
actor_account_id: &str, actor_account_id: &str,
) -> Result<RepositorySshCredential> { ) -> Result<RepositorySshCredential> {
let credential_id = validate_identifier("credential_id", credential_id)?; 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 operation_id = validate_identifier("operation_id", &request.operation_id)?;
let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?; let parsed = parse_private_key(&request.private_key, request.passphrase.as_deref())?;
let next_revision = request let next_revision = request
@@ -529,6 +718,11 @@ impl RepositorySecretService {
projection: &RepositoryAccessProjection, projection: &RepositoryAccessProjection,
) -> Result<()> { ) -> Result<()> {
let credential_id = validate_identifier("credential_id", credential_id)?; 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 operation_id = validate_identifier("operation_id", &request.operation_id)?;
let references = credential_references(projection, &credential_id); let references = credential_references(projection, &credential_id);
if !references.is_empty() { 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( pub fn lease_ssh_materialization_access(
&self, &self,
workspace_id: &str, workspace_id: &str,
@@ -1056,6 +1317,7 @@ impl RepositorySecretService {
struct ParsedKey { struct ParsedKey {
algorithm: String, algorithm: String,
fingerprint: String, fingerprint: String,
public_key: String,
} }
fn parse_private_key(private_key: &str, passphrase: Option<&str>) -> Result<ParsedKey> { 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 { Ok(ParsedKey {
algorithm: public_key.algorithm().to_string(), algorithm: public_key.algorithm().to_string(),
fingerprint: public_key.fingerprint(HashAlg::Sha256).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)); 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] #[test]
fn credential_create_rotate_replay_and_cross_workspace_scope_keep_secrets_write_only() { fn credential_create_rotate_replay_and_cross_workspace_scope_keep_secrets_write_only() {
let (_dir, store, service) = test_service(); 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] #[test]
fn referenced_resources_cannot_be_deleted() { fn referenced_resources_cannot_be_deleted() {
let (_dir, _store, service) = test_service(); let (_dir, _store, service) = test_service();
+11 -2
View File
@@ -397,7 +397,13 @@ mod tests {
"1", "1",
i64::MAX, i64::MAX,
RepositorySshAccessSecret { RepositorySshAccessSecret {
private_key: "private-key-bytes".to_string(), 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(), known_hosts_entry: "known-hosts-entry".to_string(),
}, },
) )
@@ -419,7 +425,10 @@ mod tests {
assert!(!debug.contains("private-key-bytes")); assert!(!debug.contains("private-key-bytes"));
assert!(debug.contains("REDACTED")); assert!(debug.contains("REDACTED"));
let secret: RepositorySshAccessSecret = serde_json::from_slice(&response.bytes).unwrap(); 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!( assert!(matches!(
broker.fetch_resource(request(handle, "runtime-test", None)), broker.fetch_resource(request(handle, "runtime-test", None)),
Err(BackendResourceError::MissingResource) Err(BackendResourceError::MissingResource)
@@ -10,12 +10,11 @@ use protocol::subscription::{
SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode, SubscriptionRequestId, SubscriptionResponse, SubscriptionSnapshot, SubscriptionTerminationCode,
}; };
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest; use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use worker_runtime::auth::{CapabilityTokenSigner, capability_claims}; use tokio_tungstenite::{client_async_tls_with_config, connect_async};
use crate::hosts::RemoteRuntimeConfig; use crate::hosts::{RemoteRuntimeConfig, resolve_strict_remote_runtime_endpoint};
const DOWNSTREAM_QUEUE_CAPACITY: usize = 256; const DOWNSTREAM_QUEUE_CAPACITY: usize = 256;
const RECONNECT_DELAY: Duration = Duration::from_millis(100); const RECONNECT_DELAY: Duration = Duration::from_millis(100);
@@ -918,10 +917,43 @@ async fn connect_runtime(
.map_err(|error| format!("invalid Runtime authorization header: {error}"))?, .map_err(|error| format!("invalid Runtime authorization header: {error}"))?,
); );
} }
connect_async(request) if config.strict_public_egress {
let base_url = config.base_url.clone();
let (_, addresses) =
tokio::task::spawn_blocking(move || resolve_strict_remote_runtime_endpoint(&base_url))
.await
.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 .await
.map_err(|_| "Runtime subscription TCP connection timed out".to_string())??;
tokio::time::timeout(
config.timeout,
client_async_tls_with_config(request, stream, None, None),
)
.await
.map_err(|_| "Runtime subscription TLS/WebSocket handshake timed out".to_string())?
.map(|(socket, _)| socket) .map(|(socket, _)| socket)
.map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}")) .map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}"))
} else {
tokio::time::timeout(config.timeout, connect_async(request))
.await
.map_err(|_| "Runtime subscription connection timed out".to_string())?
.map(|(socket, _)| socket)
.map_err(|error| format!("failed to connect Runtime subscription endpoint: {error}"))
}
} }
fn runtime_endpoint(base_url: &str) -> String { fn runtime_endpoint(base_url: &str) -> String {
let base = base_url.trim_end_matches('/'); let base = base_url.trim_end_matches('/');
@@ -935,24 +967,15 @@ fn runtime_endpoint(base_url: &str) -> String {
} }
fn runtime_token( fn runtime_token(
config: &RemoteRuntimeConfig, config: &RemoteRuntimeConfig,
workspace_id: &str, _workspace_id: &str,
) -> Result<Option<String>, String> { ) -> Result<Option<String>, String> {
let Some(auth) = config.auth.as_ref() else { if let Some(authorization) = config.workspace_authorization.as_ref() {
return Ok(config.bearer_token.clone()); return authorization
}; .issue("GET", "/v1/protocol/ws", "workers:list", None, &[])
let signer = CapabilityTokenSigner::new(&auth.server_id, &auth.server_private_key); .map(Some)
let claims = capability_claims( .map_err(|error| error.message);
&auth.server_id, }
&config.runtime_id, Ok(config.bearer_token.clone())
workspace_id,
vec!["workers:list".into()],
300,
)
.map_err(|error| error.to_string())?;
signer
.sign(&claims)
.map(Some)
.map_err(|error| error.to_string())
} }
fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) { fn update_status(status: &RwLock<RuntimeSubscriptionBrokerStatus>, state: &State, connected: bool) {
*status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus { *status.write().expect("broker status poisoned") = RuntimeSubscriptionBrokerStatus {
@@ -19,6 +19,10 @@ impl WorkerExecutionBackend for TestExecutionBackend {
fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
WorkerExecutionSpawnResult::connected( WorkerExecutionSpawnResult::connected(
WorkerExecutionHandle::new(request.worker_ref, self.backend_id()), WorkerExecutionHandle::new(request.worker_ref, self.backend_id()),
protocol::WorkerStateSnapshot {
execution_generation: request.run_generation,
..protocol::WorkerStatus::Idle.into()
},
None, None,
) )
} }
@@ -174,12 +178,18 @@ async fn equal_downstream_selectors_share_one_upstream_subscription() {
.await; .await;
assert_eq!(status.desired_selectors, 1); 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 runtime
.observe_worker_event( .observe_worker_event(
&worker.worker_ref, &worker.worker_ref,
protocol::Event::WorkerState { protocol::Event::WorkerState { snapshot: running },
snapshot: protocol::WorkerStatus::Running.into(),
},
) )
.unwrap(); .unwrap();
for subscription in [&mut first, &mut second] { 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(), workers[0].runtime_id.as_deref(),
Some("embedded-worker-runtime") 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 runtime
.observe_worker_event( .observe_worker_event(
&worker.worker_ref, &worker.worker_ref,
protocol::Event::WorkerState { protocol::Event::WorkerState { snapshot: running },
snapshot: protocol::WorkerStatus::Running.into(),
},
) )
.unwrap(); .unwrap();
assert!(matches!(next_event(&mut subscription).await, assert!(matches!(next_event(&mut subscription).await,
@@ -416,3 +432,19 @@ async fn embedded_runtime_uses_in_process_subscription_source() {
)); ));
server.abort(); server.abort();
} }
#[tokio::test]
async fn strict_runtime_subscription_rejects_private_endpoint_before_websocket_connect() {
let config = RemoteRuntimeConfig::new(
"runtime-private",
"Private Runtime",
"https://169.254.169.254",
None,
)
.with_strict_public_egress(true);
let error = match connect_runtime(&config, "workspace-a").await {
Err(error) => error,
Ok(_) => panic!("private endpoint unexpectedly produced a WebSocket"),
};
assert!(error.contains("endpoint host is not public"), "{error}");
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -172,7 +172,6 @@ impl SqliteWorkspaceStore {
host_trust_id: &str, host_trust_id: &str,
host_trust_revision: u64, host_trust_revision: u64,
repository_access_mode: &str, repository_access_mode: &str,
cache_generation: u64,
now: &str, now: &str,
) -> Result<WorkdirCreateOperationRecord> { ) -> Result<WorkdirCreateOperationRecord> {
self.with_conn_mut(|conn| { self.with_conn_mut(|conn| {
@@ -194,7 +193,6 @@ impl SqliteWorkspaceStore {
|| operation.host_trust_revision != Some(host_trust_revision) || operation.host_trust_revision != Some(host_trust_revision)
|| operation.repository_access_mode.as_deref() || operation.repository_access_mode.as_deref()
!= Some(repository_access_mode) != Some(repository_access_mode)
|| operation.cache_generation != cache_generation
{ {
return Err(Error::InvalidInput(format!( return Err(Error::InvalidInput(format!(
"Workdir create operation `{operation_id}` Repository access evidence changed" "Workdir create operation `{operation_id}` Repository access evidence changed"
@@ -206,8 +204,7 @@ impl SqliteWorkspaceStore {
r#"UPDATE workdir_create_operations r#"UPDATE workdir_create_operations
SET credential_id = ?4, credential_revision = ?5, SET credential_id = ?4, credential_revision = ?5,
host_trust_id = ?6, host_trust_revision = ?7, host_trust_id = ?6, host_trust_revision = ?7,
repository_access_mode = ?8, cache_generation = ?9, repository_access_mode = ?8, updated_at = ?9
updated_at = ?10
WHERE workspace_id = ?1 AND operation_id = ?2 WHERE workspace_id = ?1 AND operation_id = ?2
AND request_fingerprint = ?3 AND credential_id IS NULL"#, AND request_fingerprint = ?3 AND credential_id IS NULL"#,
params![ params![
@@ -223,9 +220,6 @@ impl SqliteWorkspaceStore {
"host-trust revision is out of range".to_string() "host-trust revision is out of range".to_string()
))?, ))?,
repository_access_mode, repository_access_mode,
i64::try_from(cache_generation).map_err(|_| Error::InvalidInput(
"cache generation is out of range".to_string()
))?,
now, now,
], ],
)?; )?;
@@ -294,7 +288,7 @@ fn read_workdir_create_operation(
config_projection_digest, source_kind, source_uri, source_revision, config_projection_digest, source_kind, source_uri, source_revision,
source_fingerprint, credential_id, credential_revision, source_fingerprint, credential_id, credential_revision,
host_trust_id, host_trust_revision, repository_access_mode, 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 created_at, updated_at
FROM workdir_create_operations FROM workdir_create_operations
WHERE workspace_id = ?1 AND operation_id = ?2"#, 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_id: row.get(15)?,
host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64), host_trust_revision: row.get::<_, Option<i64>>(16)?.map(|value| value as u64),
repository_access_mode: row.get(17)?, repository_access_mode: row.get(17)?,
cache_generation: row.get::<_, i64>(18)? as u64, working_directory_id: row.get(18)?,
working_directory_id: row.get(19)?, state: row.get(19)?,
state: row.get(20)?, failure: row.get(20)?,
failure: row.get(21)?, created_at: row.get(21)?,
created_at: row.get(22)?, updated_at: row.get(22)?,
updated_at: row.get(23)?,
}) })
}, },
) )
@@ -410,7 +403,6 @@ mod tests {
host_trust_id: None, host_trust_id: None,
host_trust_revision: None, host_trust_revision: None,
repository_access_mode: None, repository_access_mode: None,
cache_generation: 0,
working_directory_id: "wd-1".to_string(), working_directory_id: "wd-1".to_string(),
state: "pending".to_string(), state: "pending".to_string(),
failure: None, failure: None,
@@ -431,14 +423,12 @@ mod tests {
"trust-1", "trust-1",
5, 5,
"read_only", "read_only",
2,
"2026-08-24T00:00:01Z", "2026-08-24T00:00:01Z",
) )
.unwrap(); .unwrap();
assert_eq!(bound.credential_id.as_deref(), Some("credential-1")); assert_eq!(bound.credential_id.as_deref(), Some("credential-1"));
assert_eq!(bound.credential_revision, Some(3)); assert_eq!(bound.credential_revision, Some(3));
assert_eq!(bound.host_trust_revision, Some(5)); assert_eq!(bound.host_trust_revision, Some(5));
assert_eq!(bound.cache_generation, 2);
assert!( assert!(
store store
.bind_workdir_create_repository_access( .bind_workdir_create_repository_access(
@@ -450,7 +440,6 @@ mod tests {
"trust-1", "trust-1",
5, 5,
"read_only", "read_only",
2,
"2026-08-24T00:00:02Z", "2026-08-24T00:00:02Z",
) )
.is_err() .is_err()
@@ -1096,7 +1096,6 @@ mod tests {
host_trust_id: None, host_trust_id: None,
host_trust_revision: None, host_trust_revision: None,
repository_access_mode: None, repository_access_mode: None,
cache_generation: 0,
working_directory_id: "workdir-a".to_string(), working_directory_id: "workdir-a".to_string(),
state: "pending".to_string(), state: "pending".to_string(),
failure: None, failure: None,
+13 -15
View File
@@ -10,7 +10,6 @@ use worker_runtime::auth::{
}; };
use worker_runtime::worker_source::InProcessWorkerMutationProof; use worker_runtime::worker_source::InProcessWorkerMutationProof;
use crate::hosts::RemoteRuntimeConfig;
use crate::server::{ServerConfig, WorkspaceApi}; use crate::server::{ServerConfig, WorkspaceApi};
use crate::store::ControlPlaneStore; use crate::store::ControlPlaneStore;
@@ -55,7 +54,7 @@ pub async fn verify_runtime_request_source_proof_with_store(
) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> { ) -> Result<VerifiedRuntimeRequestSource, WorkerMutationSourceProofError> {
let unverified = decode_runtime_request_source_claims(proof) let unverified = decode_runtime_request_source_claims(proof)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?; .map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, workspace_id)?; let audience = remote_audience(config, workspace_id)?;
let trusted = store let trusted = store
.get_workspace_runtime_binding(workspace_id, &unverified.iss) .get_workspace_runtime_binding(workspace_id, &unverified.iss)
.await .await
@@ -201,7 +200,7 @@ async fn verify_worker_remove_source_with(
PresentedWorkerMutationSourceProof::Remote(token) => { PresentedWorkerMutationSourceProof::Remote(token) => {
let unverified = decode_worker_mutation_source_claims(token) let unverified = decode_worker_mutation_source_claims(token)
.map_err(|_| WorkerMutationSourceProofError::Invalid)?; .map_err(|_| WorkerMutationSourceProofError::Invalid)?;
let audience = remote_audience(config, &unverified.iss, &config.workspace_id)?; let audience = remote_audience(config, &config.workspace_id)?;
let trusted = store let trusted = store
.get_workspace_runtime_binding(&config.workspace_id, &unverified.iss) .get_workspace_runtime_binding(&config.workspace_id, &unverified.iss)
.await .await
@@ -357,20 +356,19 @@ impl worker_runtime::worker_source::EmbeddedWorkerMutationDispatcher
} }
fn remote_audience<'a>( fn remote_audience<'a>(
config: &'a crate::server::ServerConfig, config: &'a ServerConfig,
runtime_id: &str,
workspace_id: &str, workspace_id: &str,
) -> Result<std::borrow::Cow<'a, str>, WorkerMutationSourceProofError> { ) -> Result<&'a str, WorkerMutationSourceProofError> {
if runtime_id == crate::hosts::EMBEDDED_RUNTIME_ID {
return Ok(std::borrow::Cow::Owned(format!("embedded:{workspace_id}")));
}
config config
.remote_runtime_sources .backend_base_url
.iter() .as_deref()
.find(|runtime| runtime.runtime_id == runtime_id) .map(str::trim)
.and_then(|runtime: &RemoteRuntimeConfig| runtime.auth.as_ref()) .filter(|audience| !audience.is_empty())
.map(|auth| std::borrow::Cow::Borrowed(auth.server_id.as_str())) .ok_or_else(|| {
.ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust) WorkerMutationSourceProofError::Authority(format!(
"Backend public URL is unavailable for Workspace `{workspace_id}` source proof verification"
))
})
} }
fn validate_in_process_claims( fn validate_in_process_claims(
+224 -12
View File
@@ -11,6 +11,9 @@ use crate::repository_source::{parse_repository_source, repository_source_finger
use crate::store::{ use crate::store::{
ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord, ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord,
}; };
use crate::workspace_signing_identity::{
WorkspaceSigningIdentityService, WorkspaceSigningMaterialStore,
};
use crate::{Error, Result}; use crate::{Error, Result};
const MAX_DISPLAY_NAME_BYTES: usize = 200; const MAX_DISPLAY_NAME_BYTES: usize = 200;
@@ -45,11 +48,21 @@ pub struct WorkspaceCreateResult {
#[derive(Clone)] #[derive(Clone)]
pub struct WorkspaceCatalogService { pub struct WorkspaceCatalogService {
store: Arc<dyn ControlPlaneStore>, store: Arc<dyn ControlPlaneStore>,
signing_identities: WorkspaceSigningIdentityService,
} }
impl WorkspaceCatalogService { impl WorkspaceCatalogService {
pub fn new(store: Arc<dyn ControlPlaneStore>) -> Self { pub fn new(
Self { store } store: Arc<dyn ControlPlaneStore>,
signing_materials: Arc<dyn WorkspaceSigningMaterialStore>,
) -> Self {
Self {
signing_identities: WorkspaceSigningIdentityService::new(
store.clone(),
signing_materials,
),
store,
}
} }
pub fn is_empty(&self) -> Result<bool> { pub fn is_empty(&self) -> Result<bool> {
@@ -118,7 +131,7 @@ impl WorkspaceCatalogService {
.map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string())) .map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string()))
}) })
.transpose()?; .transpose()?;
let workspace_id = requested_workspace_id let proposed_workspace_id = requested_workspace_id
.clone() .clone()
.unwrap_or_else(|| Uuid::now_v7().to_string()); .unwrap_or_else(|| Uuid::now_v7().to_string());
let fingerprint = workspace_create_fingerprint( let fingerprint = workspace_create_fingerprint(
@@ -130,9 +143,16 @@ impl WorkspaceCatalogService {
&default_ref, &default_ref,
); );
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let result = self let (signing_identity, identity_provisioning_operation_key) =
.store self.signing_identities.prepare_workspace_creation(
.create_workspace_bootstrap(&WorkspaceBootstrapRecord { &operation_key,
&fingerprint,
&proposed_workspace_id,
&owner_account_id,
)?;
let workspace_id = signing_identity.workspace_id.clone();
let result = self.store.create_workspace_bootstrap(
&WorkspaceBootstrapRecord {
operation_key, operation_key,
request_fingerprint: fingerprint.clone(), request_fingerprint: fingerprint.clone(),
workspace: WorkspaceRecord { workspace: WorkspaceRecord {
@@ -158,7 +178,10 @@ impl WorkspaceCatalogService {
created_at: now.clone(), created_at: now.clone(),
updated_at: now, updated_at: now,
}, },
})?; },
&signing_identity,
&identity_provisioning_operation_key,
)?;
Ok(WorkspaceCreateResult { Ok(WorkspaceCreateResult {
workspace: result.workspace, workspace: result.workspace,
repository: result.repository, repository: result.repository,
@@ -214,10 +237,45 @@ fn workspace_create_fingerprint(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*; use super::*;
use crate::store::{AccountRecord, SqliteWorkspaceStore}; use crate::store::{AccountRecord, SqliteWorkspaceStore};
use crate::workspace_signing_identity::{
InMemoryWorkspaceSigningMaterialStore, WorkspaceSigningMaterialStore,
WorkspaceSigningPrivateMaterial, identity_error,
};
use workspace_api::RepositorySourceKind; use workspace_api::RepositorySourceKind;
struct FailFirstMaterialWrite {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
fail: AtomicBool,
}
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
self.inner.load(material_ref)
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
if self.fail.swap(false, Ordering::SeqCst) {
return Err(identity_error(
"workspace_signing_identity_material_io_failed",
"injected private material write failure",
));
}
self.inner.put_if_absent(material_ref, material)
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.inner.delete(material_ref)
}
}
fn git_repository() -> tempfile::TempDir { fn git_repository() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join(".git")).unwrap(); std::fs::create_dir(dir.path().join(".git")).unwrap();
@@ -243,7 +301,12 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn create_is_atomic_and_exact_retries_converge() { async fn create_is_atomic_and_exact_retries_converge() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let service = WorkspaceCatalogService::new(store.clone()); let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository(); let repository = git_repository();
let request = WorkspaceCreateRequest { let request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(), operation_key: "request-1".to_string(),
@@ -268,6 +331,14 @@ mod tests {
replayed.workspace.workspace_id replayed.workspace.workspace_id
); );
assert_eq!(store.list_workspaces().unwrap().len(), 1); assert_eq!(store.list_workspaces().unwrap().len(), 1);
let signing_identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.expect("new Workspace signing identity");
assert_eq!(signing_identity.state, "active");
assert_eq!(signing_identity.algorithm, "ed25519");
assert!(signing_identity.public_key.is_some());
assert!(signing_identity.public_key_fingerprint.is_some());
assert_eq!( assert_eq!(
store store
.list_repositories(&created.workspace.workspace_id) .list_repositories(&created.workspace.workspace_id)
@@ -283,11 +354,137 @@ mod tests {
); );
} }
#[tokio::test]
async fn create_recovers_same_reserved_identity_after_material_write_failure() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "material-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let reserved_key = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'",
[],
|row| row.get::<_, String>(0),
)
.map_err(Error::from)
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(store.list_workspaces().unwrap().len(), 1);
}
#[tokio::test]
async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() {
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let owner_account_id = owner_account(store.as_ref());
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let service = WorkspaceCatalogService::new(store.clone(), materials.clone());
let repository = git_repository();
let request = WorkspaceCreateRequest {
operation_key: "db-failure".to_string(),
display_name: "Workspace A".to_string(),
repository: InitialRepositoryIntent {
uri: repository.path().display().to_string(),
repository_key: "main".to_string(),
default_ref: None,
},
};
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_create_identity_audit
BEFORE INSERT ON workspace_signing_identity_audit
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
)?;
Ok(())
})
.unwrap();
assert!(
service
.create(request.clone(), owner_account_id.clone())
.is_err()
);
assert!(store.list_workspaces().unwrap().is_empty());
let (reserved_key, material_ref) = store
.with_conn(|conn| {
conn.query_row(
"SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'",
[],
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
)
.map_err(Error::from)
})
.unwrap();
assert!(materials.load(&material_ref).unwrap().is_some());
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?;
Ok(())
})
.unwrap();
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceCatalogService::new(store.clone(), materials);
let created = restarted.create(request, owner_account_id).unwrap();
let identity = store
.get_workspace_signing_identity(&created.workspace.workspace_id)
.unwrap()
.unwrap();
assert_eq!(identity.key_id, reserved_key);
assert_eq!(identity.state, "active");
}
#[tokio::test] #[tokio::test]
async fn idempotency_key_reuse_with_different_payload_is_rejected() { async fn idempotency_key_reuse_with_different_payload_is_rejected() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref()); let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store); let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository(); let repository = git_repository();
let mut request = WorkspaceCreateRequest { let mut request = WorkspaceCreateRequest {
operation_key: "request-1".to_string(), operation_key: "request-1".to_string(),
@@ -338,7 +535,12 @@ mod tests {
updated_at: "2026-07-03T00:00:00Z".to_string(), updated_at: "2026-07-03T00:00:00Z".to_string(),
}) })
.unwrap(); .unwrap();
let service = WorkspaceCatalogService::new(store); let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository = git_repository(); let repository = git_repository();
let error = service let error = service
.create( .create(
@@ -373,7 +575,12 @@ mod tests {
updated_at: "2026-07-03T00:00:00Z".to_string(), updated_at: "2026-07-03T00:00:00Z".to_string(),
}) })
.unwrap(); .unwrap();
let service = WorkspaceCatalogService::new(store); let service = WorkspaceCatalogService::new(
store,
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let repository_a = git_repository(); let repository_a = git_repository();
let repository_b = git_repository(); let repository_b = git_repository();
let created_a = service let created_a = service
@@ -425,7 +632,12 @@ mod tests {
fn remote_repository_creation_persists_typed_source_without_auth_metadata() { fn remote_repository_creation_persists_typed_source_without_auth_metadata() {
let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap());
let owner_account_id = owner_account(store.as_ref()); let owner_account_id = owner_account(store.as_ref());
let service = WorkspaceCatalogService::new(store.clone()); let service = WorkspaceCatalogService::new(
store.clone(),
Arc::new(
crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore::default(),
),
);
let result = service let result = service
.create( .create(
WorkspaceCreateRequest { WorkspaceCreateRequest {
@@ -80,6 +80,10 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"workspace_resource_keys", "workspace_resource_keys",
"workspace_runtime_binding_audit", "workspace_runtime_binding_audit",
"workspace_runtime_bindings", "workspace_runtime_bindings",
"workspace_runtime_verifications",
"workspace_signing_identities",
"workspace_signing_identity_audit",
"workspace_signing_identity_provisioning_operations",
"workspace_worker_retention_policies", "workspace_worker_retention_policies",
"workspace_worker_retention_policy_revisions", "workspace_worker_retention_policy_revisions",
]; ];
@@ -0,0 +1,880 @@
use std::fmt;
use std::fs::{self, OpenOptions};
use std::io::Write;
use std::path::{Component, Path, PathBuf};
use std::sync::Arc;
use chrono::{SecondsFormat, Utc};
use ring::signature::KeyPair;
use serde::{Deserialize, Serialize};
use worker_runtime::auth::{RuntimeIdentityMaterial, encode_public_key};
use worker_runtime::workspace_issuer::{
WorkspaceCapabilityClaims, WorkspaceCapabilityVerificationError,
assemble_workspace_capability_token, workspace_capability_signing_input,
};
use zeroize::Zeroize;
use crate::store::{
ControlPlaneStore, WorkspaceSigningIdentityActivation,
WorkspaceSigningIdentityProvisioningOperation, WorkspaceSigningIdentityRecord,
};
use crate::{Error, Result};
pub const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519";
pub const WORKSPACE_SIGNING_IDENTITY_REVISION: u64 = 1;
const MATERIAL_SCHEMA_VERSION: u32 = 1;
#[derive(Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct WorkspaceSigningPrivateMaterial {
version: u32,
workspace_id: String,
key_id: String,
revision: u64,
private_key: String,
}
impl fmt::Debug for WorkspaceSigningPrivateMaterial {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("WorkspaceSigningPrivateMaterial")
.field("version", &self.version)
.field("workspace_id", &self.workspace_id)
.field("key_id", &self.key_id)
.field("revision", &self.revision)
.field("private_key", &"[REDACTED]")
.finish()
}
}
impl Drop for WorkspaceSigningPrivateMaterial {
fn drop(&mut self) {
self.private_key.zeroize();
}
}
impl WorkspaceSigningPrivateMaterial {
pub fn generate(workspace_id: &str, key_id: &str) -> Result<Self> {
let material = RuntimeIdentityMaterial::generate(key_id.to_string()).map_err(|error| {
identity_error(
"workspace_signing_identity_generation_failed",
format!("failed to generate Workspace signing identity: {error}"),
)
})?;
Ok(Self {
version: MATERIAL_SCHEMA_VERSION,
workspace_id: workspace_id.to_string(),
key_id: key_id.to_string(),
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
private_key: material.private_key,
})
}
fn signing_key(
&self,
expected_workspace_id: &str,
expected_key_id: &str,
expected_revision: u64,
) -> Result<ring::signature::Ed25519KeyPair> {
if self.version != MATERIAL_SCHEMA_VERSION
|| self.workspace_id != expected_workspace_id
|| self.key_id != expected_key_id
|| self.revision != expected_revision
{
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match its persisted metadata",
));
}
RuntimeIdentityMaterial {
identity_id: self.key_id.clone(),
public_key: String::new(),
private_key: self.private_key.clone(),
}
.signing_key()
.map_err(|_| {
identity_error(
"workspace_signing_identity_material_corrupt",
"Workspace signing private material is corrupt",
)
})
}
pub fn validate_and_public_key(
&self,
expected_workspace_id: &str,
expected_key_id: &str,
expected_revision: u64,
) -> Result<String> {
let signing_key =
self.signing_key(expected_workspace_id, expected_key_id, expected_revision)?;
Ok(encode_public_key(signing_key.public_key().as_ref()))
}
}
pub trait WorkspaceSigningMaterialStore: Send + Sync {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>>;
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial>;
fn delete(&self, material_ref: &str) -> Result<()>;
}
#[derive(Clone)]
pub struct WorkspaceSigningIdentityService {
store: Arc<dyn ControlPlaneStore>,
materials: Arc<dyn WorkspaceSigningMaterialStore>,
}
impl WorkspaceSigningIdentityService {
pub fn new(
store: Arc<dyn ControlPlaneStore>,
materials: Arc<dyn WorkspaceSigningMaterialStore>,
) -> Self {
Self { store, materials }
}
pub fn prepare_workspace_creation(
&self,
workspace_create_operation_key: &str,
request_fingerprint: &str,
proposed_workspace_id: &str,
actor_account_id: &str,
) -> Result<(WorkspaceSigningIdentityActivation, String)> {
let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true);
let proposed_key_id = format!("WK-{}", uuid::Uuid::now_v7().simple());
let proposed_material_ref = format!("workspace-signing/{proposed_workspace_id}/ed25519-v1");
let operation_key = format!("workspace-create:{workspace_create_operation_key}");
let operation = self.store.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: operation_key.clone(),
request_fingerprint: request_fingerprint.to_string(),
operation_kind: "workspace_create".to_string(),
workspace_id: proposed_workspace_id.to_string(),
key_id: proposed_key_id,
private_material_ref: proposed_material_ref,
revision: WORKSPACE_SIGNING_IDENTITY_REVISION,
actor_account_id: actor_account_id.to_string(),
state: "pending".to_string(),
created_at: now,
completed_at: None,
},
)?;
let activation = self.prepare_material(&operation)?;
Ok((activation, operation.operation_key))
}
pub fn provision_existing(
&self,
workspace_id: &str,
actor_account_id: &str,
) -> Result<WorkspaceSigningIdentityRecord> {
let identity = self
.store
.get_workspace_signing_identity(workspace_id)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_metadata_missing",
"Workspace signing identity metadata is missing",
)
})?;
if identity.state == "active" {
self.validate_active_material(&identity)?;
return Ok(identity);
}
if identity.state != "pending_provisioning" {
return Err(identity_error(
"workspace_signing_identity_state_invalid",
"Workspace signing identity state is invalid",
));
}
let operation_key = format!(
"existing-workspace:{workspace_id}:revision-{}",
identity.revision
);
let request_fingerprint =
provisioning_fingerprint(workspace_id, &identity.key_id, identity.revision);
let operation = self.store.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: operation_key.clone(),
request_fingerprint,
operation_kind: "existing_workspace".to_string(),
workspace_id: workspace_id.to_string(),
key_id: identity.key_id.clone(),
private_material_ref: identity.private_material_ref.clone(),
revision: identity.revision,
actor_account_id: actor_account_id.to_string(),
state: "pending".to_string(),
created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
completed_at: None,
},
)?;
let activation = self.prepare_material(&operation)?;
self.store.activate_workspace_signing_identity(
&activation,
&operation.operation_key,
&operation.actor_account_id,
)
}
pub fn get_validated(&self, workspace_id: &str) -> Result<WorkspaceSigningIdentityRecord> {
let identity = self
.store
.get_workspace_signing_identity(workspace_id)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_metadata_missing",
"Workspace signing identity metadata is missing",
)
})?;
if identity.state == "active" {
self.validate_active_material(&identity)?;
}
Ok(identity)
}
pub fn sign(&self, workspace_id: &str, payload: &[u8]) -> Result<Vec<u8>> {
let identity = self.get_validated(workspace_id)?;
if identity.state != "active" {
return Err(identity_error(
"workspace_signing_identity_not_provisioned",
"Workspace signing identity is not provisioned",
));
}
let material = self
.materials
.load(&identity.private_material_ref)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing",
)
})?;
let signing_key =
material.signing_key(workspace_id, &identity.key_id, identity.revision)?;
Ok(signing_key.sign(payload).as_ref().to_vec())
}
pub fn issue_workspace_capability(
&self,
workspace_id: &str,
claims: &WorkspaceCapabilityClaims,
) -> Result<String> {
let input = workspace_capability_signing_input(claims).map_err(capability_error)?;
let signature = self.sign(workspace_id, input.bytes())?;
assemble_workspace_capability_token(input, &signature).map_err(capability_error)
}
pub fn delete_material(&self, workspace_id: &str) -> Result<()> {
if let Some(identity) = self.store.get_workspace_signing_identity(workspace_id)? {
self.materials.delete(&identity.private_material_ref)?;
}
Ok(())
}
fn prepare_material(
&self,
operation: &WorkspaceSigningIdentityProvisioningOperation,
) -> Result<WorkspaceSigningIdentityActivation> {
let material = match self.materials.load(&operation.private_material_ref)? {
Some(material) => material,
None => {
let generated = WorkspaceSigningPrivateMaterial::generate(
&operation.workspace_id,
&operation.key_id,
)?;
self.materials
.put_if_absent(&operation.private_material_ref, &generated)?
}
};
let public_key = material.validate_and_public_key(
&operation.workspace_id,
&operation.key_id,
operation.revision,
)?;
let public_key_fingerprint = public_key_fingerprint(&public_key)?;
Ok(WorkspaceSigningIdentityActivation {
workspace_id: operation.workspace_id.clone(),
key_id: operation.key_id.clone(),
public_key,
public_key_fingerprint,
private_material_ref: operation.private_material_ref.clone(),
revision: operation.revision,
provisioned_at: Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
})
}
fn validate_active_material(&self, identity: &WorkspaceSigningIdentityRecord) -> Result<()> {
let material = self
.materials
.load(&identity.private_material_ref)?
.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing",
)
})?;
let public_key = material.validate_and_public_key(
&identity.workspace_id,
&identity.key_id,
identity.revision,
)?;
let fingerprint = public_key_fingerprint(&public_key)?;
if identity.public_key.as_deref() != Some(public_key.as_str())
|| identity.public_key_fingerprint.as_deref() != Some(fingerprint.as_str())
{
return Err(identity_error(
"workspace_signing_identity_material_mismatch",
"Workspace signing private material does not match public metadata",
));
}
Ok(())
}
}
fn provisioning_fingerprint(workspace_id: &str, key_id: &str, revision: u64) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(workspace_id.as_bytes());
hasher.update([0]);
hasher.update(key_id.as_bytes());
hasher.update([0]);
hasher.update(revision.to_be_bytes());
format!("sha256:{}", hex_lower(&hasher.finalize()))
}
#[derive(Default)]
pub struct InMemoryWorkspaceSigningMaterialStore {
materials: std::sync::Mutex<std::collections::HashMap<String, WorkspaceSigningPrivateMaterial>>,
}
impl WorkspaceSigningMaterialStore for InMemoryWorkspaceSigningMaterialStore {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
Ok(self
.materials
.lock()
.expect("identity material store lock")
.get(material_ref)
.cloned())
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
let mut materials = self.materials.lock().expect("identity material store lock");
Ok(materials
.entry(material_ref.to_string())
.or_insert_with(|| material.clone())
.clone())
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.materials
.lock()
.expect("identity material store lock")
.remove(material_ref);
Ok(())
}
}
#[derive(Clone, Debug)]
pub struct FsWorkspaceSigningMaterialStore {
root: PathBuf,
}
impl FsWorkspaceSigningMaterialStore {
pub fn new(root: PathBuf) -> Self {
Self { root }
}
fn material_path(&self, material_ref: &str) -> Result<PathBuf> {
let relative = Path::new(material_ref);
if relative.as_os_str().is_empty()
|| relative.is_absolute()
|| relative.components().any(|component| {
!matches!(component, Component::Normal(_))
|| component.as_os_str().to_string_lossy().starts_with('.')
})
{
return Err(identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference is invalid",
));
}
Ok(self.root.join(relative).with_extension("json"))
}
}
impl WorkspaceSigningMaterialStore for FsWorkspaceSigningMaterialStore {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
let path = self.material_path(material_ref)?;
let bytes = match fs::read(path) {
Ok(bytes) => bytes,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(material_io_error("read", error)),
};
serde_json::from_slice(&bytes).map(Some).map_err(|_| {
identity_error(
"workspace_signing_identity_material_corrupt",
"Workspace signing private material is corrupt",
)
})
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
let path = self.material_path(material_ref)?;
let parent = path.parent().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference has no parent",
)
})?;
ensure_private_tree(&self.root, parent)?;
let mut bytes = serde_json::to_vec(material).map_err(|_| {
identity_error(
"workspace_signing_identity_material_encode_failed",
"Workspace signing private material could not be encoded",
)
})?;
let temporary = parent.join(format!(
".workspace-signing-{}.tmp",
uuid::Uuid::now_v7().simple()
));
let write_result = (|| -> Result<()> {
let mut options = OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options
.open(&temporary)
.map_err(|error| material_io_error("create", error))?;
file.write_all(&bytes)
.and_then(|()| file.sync_all())
.map_err(|error| material_io_error("write", error))?;
match fs::hard_link(&temporary, &path) {
Ok(()) => sync_directory(parent),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => Ok(()),
Err(error) => Err(material_io_error("publish", error)),
}
})();
bytes.zeroize();
let cleanup_result = match fs::remove_file(&temporary) {
Ok(()) => sync_directory(parent),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(material_io_error("remove temporary", error)),
};
write_result?;
cleanup_result?;
self.load(material_ref)?.ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_missing",
"Workspace signing private material is missing after publication",
)
})
}
fn delete(&self, material_ref: &str) -> Result<()> {
let path = self.material_path(material_ref)?;
match fs::remove_file(&path) {
Ok(()) => {
let parent = path.parent().ok_or_else(|| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material reference has no parent",
)
})?;
sync_directory(parent)
}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(material_io_error("delete", error)),
}
}
}
fn ensure_private_tree(root: &Path, leaf: &Path) -> Result<()> {
ensure_private_directory(root)?;
if let Some(parent) = root.parent() {
sync_directory(parent)?;
}
let relative = leaf.strip_prefix(root).map_err(|_| {
identity_error(
"workspace_signing_identity_material_ref_invalid",
"Workspace signing private material path escapes its authority root",
)
})?;
let mut current = root.to_path_buf();
for component in relative.components() {
let parent = current.clone();
current.push(component);
ensure_private_directory(&current)?;
sync_directory(&parent)?;
}
Ok(())
}
#[cfg(unix)]
fn sync_directory(path: &Path) -> Result<()> {
std::fs::File::open(path)
.and_then(|directory| directory.sync_all())
.map_err(|error| material_io_error("synchronize directory", error))
}
#[cfg(not(unix))]
fn sync_directory(_path: &Path) -> Result<()> {
Err(identity_error(
"workspace_signing_identity_durable_publish_unsupported",
"Workspace signing private material durable publication is unsupported on this platform",
))
}
fn ensure_private_directory(path: &Path) -> Result<()> {
fs::create_dir_all(path).map_err(|error| material_io_error("create directory", error))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))
.map_err(|error| material_io_error("set directory permissions", error))?;
}
Ok(())
}
pub fn workspace_signing_material_root(database_path: &Path) -> PathBuf {
database_path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("workspace-signing-identities")
}
pub fn public_key_fingerprint(public_key: &str) -> Result<String> {
let bytes = worker_runtime::auth::decode_public_key(public_key).map_err(|_| {
identity_error(
"workspace_signing_identity_public_key_invalid",
"Workspace signing public key is invalid",
)
})?;
use sha2::{Digest, Sha256};
Ok(format!("sha256:{}", hex_lower(&Sha256::digest(bytes))))
}
fn hex_lower(bytes: &[u8]) -> String {
const HEX: &[u8; 16] = b"0123456789abcdef";
let mut output = String::with_capacity(bytes.len() * 2);
for byte in bytes {
output.push(HEX[(byte >> 4) as usize] as char);
output.push(HEX[(byte & 0x0f) as usize] as char);
}
output
}
fn material_io_error(action: &str, error: std::io::Error) -> Error {
identity_error(
"workspace_signing_identity_material_io_failed",
format!("failed to {action} Workspace signing private material: {error}"),
)
}
fn capability_error(error: WorkspaceCapabilityVerificationError) -> Error {
identity_error(
"workspace_capability_issuance_failed",
format!("failed to issue Workspace capability: {error}"),
)
}
pub fn identity_error(code: impl Into<String>, message: impl Into<String>) -> Error {
Error::WorkspaceSigningIdentity {
code: code.into(),
message: message.into(),
}
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicBool, Ordering};
use super::*;
struct FailFirstMaterialWrite {
inner: Arc<InMemoryWorkspaceSigningMaterialStore>,
fail: AtomicBool,
}
impl WorkspaceSigningMaterialStore for FailFirstMaterialWrite {
fn load(&self, material_ref: &str) -> Result<Option<WorkspaceSigningPrivateMaterial>> {
self.inner.load(material_ref)
}
fn put_if_absent(
&self,
material_ref: &str,
material: &WorkspaceSigningPrivateMaterial,
) -> Result<WorkspaceSigningPrivateMaterial> {
if self.fail.swap(false, Ordering::SeqCst) {
return Err(identity_error(
"workspace_signing_identity_material_io_failed",
"injected private material write failure",
));
}
self.inner.put_if_absent(material_ref, material)
}
fn delete(&self, material_ref: &str) -> Result<()> {
self.inner.delete(material_ref)
}
}
#[tokio::test]
async fn existing_workspace_provisioning_is_audited_idempotent_and_fails_closed_when_missing() {
use crate::store::{AccountRecord, SqliteWorkspaceStore, WorkspaceRecord};
let temp = tempfile::tempdir().unwrap();
let database_path = temp.path().join("server.db");
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
store
.upsert_account(&AccountRecord {
account_id: "account-1".to_string(),
kind: "user".to_string(),
handle: "owner".to_string(),
display_name: "Owner".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-1".to_string(),
owner_account_id: "account-1".to_string(),
display_name: "Workspace".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.await
.unwrap();
let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default());
let failing_service = WorkspaceSigningIdentityService::new(
store.clone(),
Arc::new(FailFirstMaterialWrite {
inner: materials.clone(),
fail: AtomicBool::new(true),
}),
);
assert_eq!(
failing_service.get_validated("workspace-1").unwrap().state,
"pending_provisioning"
);
let error = failing_service
.provision_existing("workspace-1", "account-1")
.unwrap_err();
assert!(matches!(
error,
Error::WorkspaceSigningIdentity { ref code, .. }
if code == "workspace_signing_identity_material_io_failed"
));
assert_eq!(
store
.get_workspace_signing_identity("workspace-1")
.unwrap()
.unwrap()
.state,
"pending_provisioning"
);
drop(failing_service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let provisioned = service
.provision_existing("workspace-1", "account-1")
.unwrap();
assert_eq!(provisioned.state, "active");
assert!(provisioned.public_key.is_some());
let payload = b"Workspace authority proof";
let signature = service.sign("workspace-1", payload).unwrap();
let public_key =
worker_runtime::auth::decode_public_key(provisioned.public_key.as_deref().unwrap())
.unwrap();
ring::signature::UnparsedPublicKey::new(&ring::signature::ED25519, public_key)
.verify(payload, &signature)
.unwrap();
assert_eq!(
service
.provision_existing("workspace-1", "account-1")
.unwrap(),
provisioned
);
store
.with_conn(|conn| {
assert_eq!(
conn.query_row(
"SELECT COUNT(*) FROM workspace_signing_identity_audit WHERE workspace_id = 'workspace-1'",
[],
|row| row.get::<_, i64>(0),
)?,
1
);
Ok(())
})
.unwrap();
store
.upsert_workspace(&WorkspaceRecord {
workspace_id: "workspace-2".to_string(),
owner_account_id: "account-1".to_string(),
display_name: "Workspace 2".to_string(),
state: "active".to_string(),
created_at: "1".to_string(),
updated_at: "1".to_string(),
})
.await
.unwrap();
let pending = store
.get_workspace_signing_identity("workspace-2")
.unwrap()
.unwrap();
let operation = store
.reserve_workspace_signing_identity_provisioning(
&WorkspaceSigningIdentityProvisioningOperation {
operation_key: "existing-workspace:workspace-2:revision-1".to_string(),
request_fingerprint: provisioning_fingerprint(
"workspace-2",
&pending.key_id,
pending.revision,
),
operation_kind: "existing_workspace".to_string(),
workspace_id: "workspace-2".to_string(),
key_id: pending.key_id.clone(),
private_material_ref: pending.private_material_ref.clone(),
revision: pending.revision,
actor_account_id: "account-1".to_string(),
state: "pending".to_string(),
created_at: "1".to_string(),
completed_at: None,
},
)
.unwrap();
let activation = service.prepare_material(&operation).unwrap();
store
.with_conn(|conn| {
conn.execute_batch(
r#"CREATE TRIGGER fail_workspace_signing_identity_audit
BEFORE INSERT ON workspace_signing_identity_audit
BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#,
)?;
Ok(())
})
.unwrap();
assert!(
store
.activate_workspace_signing_identity(
&activation,
&operation.operation_key,
"account-1",
)
.is_err()
);
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER fail_workspace_signing_identity_audit;")?;
Ok(())
})
.unwrap();
assert_eq!(
store
.get_workspace_signing_identity("workspace-2")
.unwrap()
.unwrap()
.state,
"pending_provisioning"
);
drop(service);
drop(store);
let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap());
let restarted = WorkspaceSigningIdentityService::new(store.clone(), materials.clone());
let recovered = restarted
.provision_existing("workspace-2", "account-1")
.unwrap();
assert_eq!(recovered.key_id, activation.key_id);
assert_eq!(
recovered.public_key_fingerprint.as_deref(),
Some(activation.public_key_fingerprint.as_str())
);
materials.delete(&provisioned.private_material_ref).unwrap();
let error = restarted.get_validated("workspace-1").unwrap_err();
assert!(matches!(
error,
Error::WorkspaceSigningIdentity { ref code, .. }
if code == "workspace_signing_identity_material_missing"
));
}
#[test]
fn file_store_round_trips_private_material_without_overwrite() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
let first = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let first_public = first.validate_and_public_key("ws-1", "WK-1", 1).unwrap();
let persisted = store.put_if_absent("ws-1/ed25519-v1", &first).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(temp.path().join("identities"))
.unwrap()
.permissions()
.mode()
& 0o777,
0o700
);
assert_eq!(
fs::metadata(store.material_path("ws-1/ed25519-v1").unwrap())
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
assert_eq!(
persisted
.validate_and_public_key("ws-1", "WK-1", 1)
.unwrap(),
first_public
);
let second = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let persisted = store.put_if_absent("ws-1/ed25519-v1", &second).unwrap();
assert_eq!(
persisted
.validate_and_public_key("ws-1", "WK-1", 1)
.unwrap(),
first_public
);
}
#[test]
fn corrupt_and_cross_workspace_material_fail_closed() {
let temp = tempfile::tempdir().unwrap();
let store = FsWorkspaceSigningMaterialStore::new(temp.path().join("identities"));
let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
store.put_if_absent("ws-1/ed25519-v1", &material).unwrap();
let loaded = store.load("ws-1/ed25519-v1").unwrap().unwrap();
assert!(loaded.validate_and_public_key("ws-2", "WK-1", 1).is_err());
fs::write(store.material_path("ws-1/ed25519-v1").unwrap(), b"not json").unwrap();
assert!(store.load("ws-1/ed25519-v1").is_err());
}
}
+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. 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 ## 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. 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 ## 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. The Backend can project Runtime and Worker state, but it should not become a hidden filesystem/runtime implementation. Runtime observations should be reconstructable from Runtime APIs and committed Backend records.
Remote Runtime authentication follows the same Workspace boundary. The Server signs each Runtime request with the target Workspace signing identity, and the Runtime verifies it against the installed Workspace issuer bundle. Runtime-to-Server source proof is signed by the Runtime identity and uses the bundle's Backend URL as audience. Server-global signing identities, Runtime-side global Server trust, and static bearer fallback are not Remote Workspace authority. Provisioning and rotation are described in [Workspace ↔ Runtime authentication](../development/server-runtime-auth.md).
## Docker image layout ## Docker image layout
Docker images are built through Nix `dockerTools.buildImage`, not through a root Dockerfile. Docker images are built through Nix `dockerTools.buildImage`, not through a root Dockerfile.
+9 -27
View File
@@ -4,35 +4,15 @@ This repository is developed with Yoi itself. Dogfooding is valuable because it
## Pre-restart gate ## Pre-restart gate
Never use the live dogfood Server or Runtime as the first startup test for a new Never use the live dogfood Server or Runtime as the first startup test for a new binary. A dogfood restart is allowed only after this sequence succeeds:
binary. A dogfood restart is allowed only after this sequence succeeds:
1. Build the production entrypoints: 1. Build the production entrypoints: `cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`.
`cargo build -p worker-runtime --bin yoi-runtime -p yoi-workspace-server --bin yoi-server`. 2. Run the focused and dependent tests for the changed contracts, followed by workspace-root `cargo check`, `cargo fmt --all -- --check`, and `git diff --check HEAD`.
2. Run the focused and dependent tests for the changed contracts, followed by 3. Exercise the provisioning and operational checks in [Workspace ↔ Runtime authentication](server-runtime-auth.md) against isolated Server DB, Runtime data, and ports. The Workspace owner must create the binding, the Runtime operator must install the Workspace issuer bundle, and the challenge proof must become verified.
`cargo fmt --all -- --check` and `git diff --check HEAD`. 4. Have an external supervisor or operator restart Server and Runtime at the same generation. A Worker hosted by the target Runtime must never terminate its own Runtime.
3. Run `scripts/isolated-startup-smoke.sh` from an external shell/process. 5. Verify post-restart readiness through the Workspace Runtime projection, ping, Worker list/create, protocol subscription, and a Runtime-to-Server source-proof operation before treating the environment as healthy.
4. Inspect any failed run's retained `/tmp/yoi-isolated-startup-smoke.*` logs;
do not restart dogfood until the cause is fixed and the smoke passes.
5. Have an external supervisor or operator restart Server and Runtime. A Worker
hosted by the target Runtime must never terminate its own Runtime.
6. Verify post-restart readiness through the Workspace Runtime projection and a
real restored Worker operation before treating the environment as healthy.
The smoke harness runs the normal `yoi-server` and `yoi-runtime` binaries using The former isolated startup shell harness depended on removed Server-global trust commands and is intentionally not a fallback smoke path. New automated startup coverage must provision the same Workspace-scoped binding and challenge authority used by production rather than recreating global trust or seeding private authority directly.
separate `HOME`, `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, temporary Git repository,
Server database, Runtime fs store, identity/trust material, and non-dogfood
ports. It fails if either port is already occupied, if state escapes the
temporary root, if a process exits unexpectedly, if Runtime readiness is not
visible through Server, or if startup logs contain a panic, migration collision,
or Worker execution restore failure. It also proves that a listening Server
without its configured Runtime is not readiness and restarts the isolated
Runtime once to exercise persistence reopen.
Override `YOI_SMOKE_SERVER_BIN`, `YOI_SMOKE_RUNTIME_BIN`,
`YOI_SMOKE_SERVER_PORT`, or `YOI_SMOKE_RUNTIME_PORT` only when a separate build
or port is intentionally under test. Set `YOI_SMOKE_KEEP=1` to retain successful
artifacts. Failed artifacts are retained automatically.
## What to record ## What to record
@@ -45,6 +25,8 @@ A report is useful when it explains:
- what design boundary was missing - what design boundary was missing
- what evidence was observed - what evidence was observed
For a Remote Runtime rollout, also record the source commit, binary generation, Server schema version, Runtime binding revision, Workspace key generation, and typed HTTP/WebSocket outcomes. A successful document response does not outweigh visible UI, console, or API errors.
## Runtime command caveat ## Runtime command caveat
After rebuilding and restarting during dogfooding, `current_exe()` can point at a deleted binary path. Use typed runtime-command configuration and the development-only `YOI_POD_RUNTIME_COMMAND` executable override rather than reviving shell-command overrides. After rebuilding and restarting during dogfooding, `current_exe()` can point at a deleted binary path. Use typed runtime-command configuration and the development-only `YOI_POD_RUNTIME_COMMAND` executable override rather than reviving shell-command overrides.
+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 ## 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 ```toml
# .yoi/override.local.toml [feature.plugins]
enabled = true
[features]
plugins = true
[[plugins.enabled]] [[plugins.enabled]]
id = "project:example.echo" 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. 旧 Server identity/trust 管理 command と旧 Runtime-side Server trust command、旧 Runtime auth key flags は廃止済みである。これらに相当する Server-global trust を fallback として使ってはならない。
- Runtime owns Worker execution. It does not own a workspace registry or workspace list.
- Runtime API paths remain worker-centric; workspace scope is carried in the signed auth context and enforced by Runtime-side authorization/filtering code.
- Browser/Web clients should talk to Workspace Server, not directly to Runtime.
## Identifiers used in examples ## Provisioning
Replace these values for the deployment: 1. Runtime identity を初期化する。
```text ```sh
SERVER_ID=server-main yoi-runtime identity init --runtime-id <runtime-id>
RUNTIME_ID=runtime-main yoi-runtime identity show
RUNTIME_BASE_URL=http://127.0.0.1:38800 ```
```
`SERVER_ID` is the issuer id in Server-signed tokens. `RUNTIME_ID` is the token audience and must match the Runtime identity. 2. Workspace owner が Settings → Runtimes から Runtime public bundle と endpoint を登録する。
3. Server が Workspace issuer bundle と challenge を発行する。
4. operator が bundle を Runtime に追加する。
## 1. Create and show the Server identity ```sh
yoi-runtime trust-workspace add --bundle <workspace-issuer-bundle.json>
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 同じ Runtime identity は異なる Workspace から独立して信頼できる。trust record、replay protection、binding、失効はすべて Workspace scope で評価する。
yoi-server identity init --server-id server-main
```
Show the public identity and copy the `public_key` value: ## Runtime auth file
```bash `runtime-auth.toml` は Runtime identity と Workspace issuer records のみを authority とする。
yoi-server identity show --json 旧 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 - 最大 8 MiB
<data_dir>/server/identity.toml - 最大 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 Workspace signing key または Runtime key の変更は、現在 binding を置き換える明示的な provisioning 操作として行う。古い generation、古い Runtime key、revoked binding、失効済み token、replayed JTI は即時拒否する。
yoi-runtime identity init --runtime-id runtime-main
```
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 1. `yoi-runtime trust-workspace show --workspace-id <workspace-id>` が期待する bundle を表示する。
<data_dir>/runtime/auth.toml 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. Server / Runtime の再起動は live reload ではない authority 変更を反映するときだけ、通常の運用権限と migration gate に従って行う。実行中プロセスを開発 Worker が無断で停止してはならない。
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.
@@ -49,4 +49,4 @@ The corrupted `tool_result` was manually replaced with a synthetic repair record
## Related fixes made during investigation ## 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. - 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 # context window on this route, even when public API docs advertise larger
# model-family windows. # model-family windows.
# Codex currently caps the ChatGPT-backed route at 272k even though the public # 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 # 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 the # resolution and compaction use the effective backend limit without losing each
# underlying model capability. # 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]] [[model]]
id = "gpt-5.6-sol" id = "gpt-5.6-sol"
provider = "codex-oauth" 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": "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", "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", "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", "build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview" "preview": "deno run -A npm:vite@7.2.7 preview"
}, },
@@ -22,6 +22,20 @@ export type CreateRepositorySshCredentialRequest = {
passphrase: string | null; 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 = { export type RotateRepositorySshCredentialRequest = {
operation_id: string; operation_id: string;
expected_revision: number; expected_revision: number;
@@ -9,9 +9,7 @@ export type Diagnostic = {
message: string; message: string;
}; };
export type WorkingDirectoryMaterializerKind = export type WorkingDirectoryMaterializerKind = "runtime_git_clone";
| "runtime_git_cache"
| "local_git_worktree";
export type WorkingDirectoryStatusKind = export type WorkingDirectoryStatusKind =
| "active" | "active"
@@ -11,9 +11,7 @@ export type Diagnostic = {
message: string; message: string;
}; };
export type WorkingDirectoryMaterializerKind = export type WorkingDirectoryMaterializerKind = "runtime_git_clone";
| "runtime_git_cache"
| "local_git_worktree";
export type WorkingDirectoryStatusKind = export type WorkingDirectoryStatusKind =
| "active" | "active"
@@ -165,6 +165,35 @@ export type WorkspaceMetadataMutationResponse = {
diagnostics: Array<Diagnostic>; diagnostics: Array<Diagnostic>;
}; };
export type WorkspaceSigningIdentityState = "pending_provisioning" | "active";
export type WorkspaceSigningIdentityPublic = {
workspace_id: string;
key_id: string;
algorithm: string;
public_key?: string;
public_key_fingerprint?: string;
revision: number;
state: WorkspaceSigningIdentityState;
created_at: string;
provisioned_at?: string;
};
export type WorkspacePublicIdentityBundle = {
workspace_id: string;
backend_url: string;
key_id: string;
algorithm: string;
public_key: string;
public_key_fingerprint: string;
revision: number;
};
export type WorkspaceSigningIdentityResponse = {
identity: WorkspaceSigningIdentityPublic;
public_bundle?: WorkspacePublicIdentityBundle;
};
export type ProfileSettingsResponse = { export type ProfileSettingsResponse = {
workspace_id: string; workspace_id: string;
registry_revision: string; registry_revision: string;
@@ -278,6 +307,38 @@ export type RepositoryDetailResponse = {
source: string; 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 = { export type RepositoryLogResponse = {
workspace_id: string; workspace_id: string;
repository_key: string; repository_key: string;
@@ -315,12 +376,51 @@ export type RuntimeSummary = {
diagnostics: Array<Diagnostic>; diagnostics: Array<Diagnostic>;
}; };
export type WorkspaceRuntimeBindingState =
| "configured"
| "verified"
| "revoked";
export type RuntimeConnectionDisplayState =
| "configured"
| "verified"
| "unavailable"
| "revoked";
export type RuntimeVerificationOutcome =
| "verified"
| "challenge_issued"
| "verification_failed"
| "connectivity_failed";
export type RuntimeVerificationEvidenceSummary = {
verified_at: string | null;
last_checked_at: string;
last_outcome: RuntimeVerificationOutcome;
binding_revision: number;
workspace_key_id: string;
workspace_identity_revision: number;
workspace_trust_generation: number;
runtime_public_key_fingerprint: string;
runtime_identity_revision: number;
};
export type WorkspaceRuntimeBindingSummary = {
state: WorkspaceRuntimeBindingState;
connection_state: RuntimeConnectionDisplayState;
revision: number;
workspace_key_id?: string | null;
workspace_key_generation?: number | null;
verification?: RuntimeVerificationEvidenceSummary | null;
};
export type RuntimeManagementSummary = { export type RuntimeManagementSummary = {
built_in: boolean; built_in: boolean;
config_managed: boolean; config_managed: boolean;
removable: boolean; removable: boolean;
endpoint_configured: boolean; endpoint_configured: boolean;
token_ref_configured: boolean; token_ref_configured: boolean;
binding?: WorkspaceRuntimeBindingSummary | null;
}; };
export type WorkspaceRuntimeResource = { export type WorkspaceRuntimeResource = {
@@ -373,11 +473,6 @@ export type WorkspaceRuntimeDetail = {
export type RuntimeTrustKeyRevealResponse = { public_key: string }; export type RuntimeTrustKeyRevealResponse = { public_key: string };
export type PutRuntimeTrustKeyRequest = {
public_key: string;
expected_revision: number | null;
};
export type RevokeRuntimeTrustKeyRequest = { expected_revision: number }; export type RevokeRuntimeTrustKeyRequest = { expected_revision: number };
export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use"; export type RuntimeTrustConflictKind = "stale_revision" | "fingerprint_in_use";
@@ -389,6 +484,18 @@ export type RuntimeTrustConflictResponse = {
current_fingerprint?: string | null; current_fingerprint?: string | null;
}; };
export type RuntimePublicIdentityBundle = {
identity_id: string;
public_key: string;
};
export type CreateRemoteRuntimeRequest = {
public_bundle: RuntimePublicIdentityBundle;
display_name?: string | null;
endpoint: string;
expected_revision?: number | null;
};
export type RuntimeConnectionTestStatus = "compatible" | "failed"; export type RuntimeConnectionTestStatus = "compatible" | "failed";
export type RuntimeConnectionTestFailureKind = export type RuntimeConnectionTestFailureKind =
@@ -405,6 +512,9 @@ export type RuntimeConnectionTestFailureKind =
export type RuntimeConnectionTestResponse = { export type RuntimeConnectionTestResponse = {
workspace_id: string; workspace_id: string;
runtime_id: string; runtime_id: string;
binding_revision: number;
connection_state: RuntimeConnectionDisplayState;
verification: RuntimeVerificationEvidenceSummary | null;
checked_at: string; checked_at: string;
status: RuntimeConnectionTestStatus; status: RuntimeConnectionTestStatus;
failure_kind: RuntimeConnectionTestFailureKind | null; failure_kind: RuntimeConnectionTestFailureKind | null;
@@ -2,6 +2,7 @@ import type {
RepositoryAccessProjection, RepositoryAccessProjection,
RepositorySshCredential, RepositorySshCredential,
RepositorySshHostTrust, RepositorySshHostTrust,
RepositorySshPublicKey,
} from "../../generated/repository-access-api.ts"; } from "../../generated/repository-access-api.ts";
export class RepositoryAccessSchemaError extends Error { export class RepositoryAccessSchemaError extends Error {
@@ -50,6 +51,25 @@ export function parseRepositorySshCredential(
return record as RepositorySshCredential; 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( export function parseRepositorySshHostTrusts(
value: unknown, value: unknown,
): RepositorySshHostTrust[] { ): RepositorySshHostTrust[] {
@@ -2,11 +2,15 @@ import type {
Diagnostic, Diagnostic,
RuntimeConnectionTestFailureKind, RuntimeConnectionTestFailureKind,
RuntimeConnectionTestResponse, RuntimeConnectionTestResponse,
RuntimeVerificationEvidenceSummary,
} from "$lib/generated/workspace-api"; } from "$lib/generated/workspace-api";
const RESPONSE_KEYS = [ const RESPONSE_KEYS = [
"workspace_id", "workspace_id",
"runtime_id", "runtime_id",
"binding_revision",
"connection_state",
"verification",
"checked_at", "checked_at",
"status", "status",
"failure_kind", "failure_kind",
@@ -103,9 +107,28 @@ export function parseRuntimeConnectionTestResponse(
) { ) {
return null; return null;
} }
const bindingRevision = value.binding_revision;
const connectionState = parseConnectionState(value.connection_state);
const verification = parseVerificationEvidence(value.verification);
if (
!isSafeRevision(bindingRevision) ||
connectionState === null ||
(value.verification !== null && verification === null) ||
(verification !== null &&
verification.binding_revision !== bindingRevision) ||
(connectionState === "verified" && value.status !== "compatible") ||
(connectionState === "verified" && verification !== null &&
(verification.last_outcome !== "verified" ||
verification.verified_at === null))
) {
return null;
}
return { return {
workspace_id: value.workspace_id, workspace_id: value.workspace_id,
runtime_id: value.runtime_id, runtime_id: value.runtime_id,
binding_revision: bindingRevision,
connection_state: connectionState,
verification,
checked_at: value.checked_at, checked_at: value.checked_at,
status: value.status, status: value.status,
failure_kind: failureKind as RuntimeConnectionTestFailureKind | null, failure_kind: failureKind as RuntimeConnectionTestFailureKind | null,
@@ -115,6 +138,67 @@ export function parseRuntimeConnectionTestResponse(
}; };
} }
function parseConnectionState(
value: unknown,
): "configured" | "verified" | "unavailable" | "revoked" | null {
return value === "configured" || value === "verified" ||
value === "unavailable" || value === "revoked"
? value
: null;
}
function isSafeRevision(value: unknown): value is number {
return Number.isSafeInteger(value) && (value as number) >= 0;
}
function parseVerificationEvidence(
value: unknown,
): RuntimeVerificationEvidenceSummary | null {
if (value === null) return null;
const keys = [
"verified_at",
"last_checked_at",
"last_outcome",
"binding_revision",
"workspace_key_id",
"workspace_identity_revision",
"workspace_trust_generation",
"runtime_public_key_fingerprint",
"runtime_identity_revision",
] as const;
if (!isRecord(value) || !hasExactKeys(value, keys)) return null;
if (
(value.verified_at !== null &&
(!isBoundedString(value.verified_at, 128) ||
Number.isNaN(Date.parse(value.verified_at)))) ||
!isBoundedString(value.last_checked_at, 128) ||
Number.isNaN(Date.parse(value.last_checked_at)) ||
(value.last_outcome !== "verified" &&
value.last_outcome !== "challenge_issued" &&
value.last_outcome !== "verification_failed" &&
value.last_outcome !== "connectivity_failed") ||
!isSafeRevision(value.binding_revision) ||
!isBoundedString(value.workspace_key_id, 128) ||
!isSafeRevision(value.workspace_identity_revision) ||
!isSafeRevision(value.workspace_trust_generation) ||
!isBoundedString(value.runtime_public_key_fingerprint, 128) ||
!isSafeRevision(value.runtime_identity_revision)
) {
return null;
}
return {
verified_at: value.verified_at,
last_checked_at: value.last_checked_at,
last_outcome: value.last_outcome,
binding_revision: value.binding_revision,
workspace_key_id: value.workspace_key_id,
workspace_identity_revision: value.workspace_identity_revision,
workspace_trust_generation: value.workspace_trust_generation,
runtime_public_key_fingerprint: value.runtime_public_key_fingerprint,
runtime_identity_revision: value.runtime_identity_revision,
};
}
export async function testRuntimeConnection( export async function testRuntimeConnection(
workspaceId: string, workspaceId: string,
runtimeId: string, runtimeId: string,
@@ -1,7 +1,8 @@
import type { import type {
CreateRemoteRuntimeRequest,
Diagnostic, Diagnostic,
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, RevokeRuntimeTrustKeyRequest,
RuntimeConnectionDisplayState,
RuntimeIdentityAuthority, RuntimeIdentityAuthority,
RuntimeManagementSummary, RuntimeManagementSummary,
RuntimeSourceKind, RuntimeSourceKind,
@@ -14,6 +15,9 @@ import type {
RuntimeTrustKeyRevealResponse, RuntimeTrustKeyRevealResponse,
RuntimeTrustKeyState, RuntimeTrustKeyState,
RuntimeTrustKeyStatus, RuntimeTrustKeyStatus,
RuntimeVerificationEvidenceSummary,
WorkspaceRuntimeBindingState,
WorkspaceRuntimeBindingSummary,
WorkspaceRuntimeDetail, WorkspaceRuntimeDetail,
WorkspaceRuntimeResource, WorkspaceRuntimeResource,
} from "$lib/generated/workspace-api.ts"; } from "$lib/generated/workspace-api.ts";
@@ -67,6 +71,17 @@ const CONFLICT_KINDS = new Set<RuntimeTrustConflictKind>([
"stale_revision", "stale_revision",
"fingerprint_in_use", "fingerprint_in_use",
]); ]);
const BINDING_STATES = new Set<WorkspaceRuntimeBindingState>([
"configured",
"verified",
"revoked",
]);
const CONNECTION_STATES = new Set<RuntimeConnectionDisplayState>([
"configured",
"verified",
"unavailable",
"revoked",
]);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
type JsonObject = Record<string, unknown>; type JsonObject = Record<string, unknown>;
@@ -286,6 +301,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( function runtimeManagement(
value: unknown, value: unknown,
path: string, path: string,
@@ -300,11 +457,15 @@ function runtimeManagement(
"endpoint_configured", "endpoint_configured",
"token_ref_configured", "token_ref_configured",
], ],
[], ["binding"],
path, path,
); );
const builtIn = boolean(item.built_in, `${path}.built_in`);
const binding = item.binding == null
? undefined
: runtimeBinding(item.binding, `${path}.binding`, !builtIn);
return { return {
built_in: boolean(item.built_in, `${path}.built_in`), built_in: builtIn,
config_managed: boolean(item.config_managed, `${path}.config_managed`), config_managed: boolean(item.config_managed, `${path}.config_managed`),
removable: boolean(item.removable, `${path}.removable`), removable: boolean(item.removable, `${path}.removable`),
endpoint_configured: boolean( endpoint_configured: boolean(
@@ -315,6 +476,7 @@ function runtimeManagement(
item.token_ref_configured, item.token_ref_configured,
`${path}.token_ref_configured`, `${path}.token_ref_configured`,
), ),
...(binding === undefined ? {} : { binding }),
}; };
} }
@@ -648,6 +810,26 @@ function requestErrorFrom(
): RuntimeTrustRequestError { ): RuntimeTrustRequestError {
try { try {
const response = object(value, "Runtime trust error"); const response = object(value, "Runtime trust error");
if ("details" in response) {
exactKeys(
response,
["error", "details"],
[],
"Runtime trust error",
);
boundedString(
response.error,
"Runtime trust error.error",
LIMITS.idBytes,
);
return new RuntimeTrustRequestError(
boundedString(
response.details,
"Runtime trust error.details",
LIMITS.conflictMessageBytes,
),
);
}
exactKeys( exactKeys(
response, response,
["error", "message", "diagnostics"], ["error", "message", "diagnostics"],
@@ -713,6 +895,54 @@ async function finishMutation(
return detail; return detail;
} }
export async function createRemoteRuntime(
workspaceId: string,
request: CreateRemoteRuntimeRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeResource> {
const response = await fetchImpl(
workspaceApiPath(workspaceId, "/runtimes"),
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
},
);
const payload = await readBoundedJson(response);
if (!response.ok) throw requestErrorFrom(payload, response.status);
const runtime = runtimeResource(payload, "Runtime create response");
if (runtime.runtime_id !== request.public_bundle.identity_id) {
throw new RuntimeTrustRequestError(
"Runtime create response did not match the submitted public bundle",
);
}
return runtime;
}
export async function 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( export async function revealRuntimeTrustKey(
workspaceId: string, workspaceId: string,
runtimeId: string, runtimeId: string,
@@ -763,29 +993,6 @@ export async function previewRuntimePublicKeyFingerprint(
return `sha256:${hex}`; return `sha256:${hex}`;
} }
export async function putRuntimeTrustKey(
workspaceId: string,
runtimeId: string,
request: PutRuntimeTrustKeyRequest,
fetchImpl: typeof fetch = fetch,
): Promise<WorkspaceRuntimeDetail> {
const response = await fetchImpl(
workspaceApiPath(
workspaceId,
`/runtimes/${encodeURIComponent(runtimeId)}/trust-key`,
),
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({
public_key: request.public_key,
expected_revision: revisionForJson(request.expected_revision),
}),
},
);
return await finishMutation(response, workspaceId, runtimeId);
}
export async function revokeRuntimeTrustKey( export async function revokeRuntimeTrustKey(
workspaceId: string, workspaceId: string,
runtimeId: string, runtimeId: string,
@@ -114,8 +114,7 @@ export function parseWorkingDirectorySummary(
working_directory_id: stringField(record, "working_directory_id"), working_directory_id: stringField(record, "working_directory_id"),
repository_key: stringField(record, "repository_key"), repository_key: stringField(record, "repository_key"),
materializer_kind: enumField(record, "materializer_kind", [ materializer_kind: enumField(record, "materializer_kind", [
"runtime_git_cache", "runtime_git_clone",
"local_git_worktree",
]), ]),
status: enumField(record, "status", [ status: enumField(record, "status", [
"active", "active",
@@ -282,10 +282,7 @@ function runtimeWorkingDirectory(
item.materializer_kind, item.materializer_kind,
`${label}.materializer_kind`, `${label}.materializer_kind`,
); );
if ( if (materializerKind !== "runtime_git_clone") {
materializerKind !== "runtime_git_cache" &&
materializerKind !== "local_git_worktree"
) {
throw new Error(`${label}.materializer_kind is invalid`); throw new Error(`${label}.materializer_kind is invalid`);
} }
const status = string(item.status, `${label}.status`); const status = string(item.status, `${label}.status`);
@@ -10,6 +10,9 @@ import type {
RepositoryLogResponse, RepositoryLogResponse,
RepositorySource, RepositorySource,
RepositorySourceKind, RepositorySourceKind,
RepositorySshConnectionProbeResponse,
RepositorySshConnectionTrustState,
RepositorySshHostKeyCandidate,
RepositorySummary, RepositorySummary,
WorkspaceAuthConfig, WorkspaceAuthConfig,
WorkspaceCatalogListResponse, WorkspaceCatalogListResponse,
@@ -35,6 +38,8 @@ export type {
RepositoryDetailResponse, RepositoryDetailResponse,
RepositoryListResponse, RepositoryListResponse,
RepositoryLogResponse, RepositoryLogResponse,
RepositorySshConnectionProbeResponse,
RepositorySshHostKeyCandidate,
RepositorySummary, RepositorySummary,
WorkspaceCatalogListResponse, WorkspaceCatalogListResponse,
WorkspaceCreateResponse, 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_OPERATION_ID_BYTES = 128;
const WORKSPACE_DELETION_MAX_REVISION_BYTES = 128; const WORKSPACE_DELETION_MAX_REVISION_BYTES = 128;
const WORKSPACE_DELETION_MAX_BLOCKERS = 1024; 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", "Runtimes should be admin Settings navigation, not primary workspace sidebar navigation",
); );
assert( assert(
runtimesPage.includes("Add remote Runtime") && runtimesPage.includes("Connect a remote Runtime") &&
runtimesPage.includes("Open workdirs") && runtimesPage.includes("Open workdirs") &&
runtimesPage.includes("settings-runtime-table") && runtimesPage.includes("settings-runtime-table") &&
runtimesPage.includes("testRuntimeConnection") && 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", settingsSectionHref("configuration-sources") === "/settings/configuration",
"shared configuration editor route should stay canonical", "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) { for (const section of SETTINGS_SECTIONS) {
const href = settingsSectionHref(section.id); 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", () => { 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(section?.status === "editable", "Repositories should be editable");
assert( assert(
settingsSectionHref("repositories") === "/settings/repositories", settingsSectionHref("repositories") === "/settings/repositories",
@@ -134,7 +134,7 @@ export function settingsSectionHref(id: SettingsSectionId): string {
case "profile-sources": case "profile-sources":
return `${SETTINGS_ROUTE}/profiles`; return `${SETTINGS_ROUTE}/profiles`;
case "workspace-identity": case "workspace-identity":
return `${SETTINGS_ROUTE}/workspace`; return SETTINGS_ROUTE;
} }
} }
@@ -8,6 +8,10 @@ import type {
WorkspaceProfileSourceProvenance, WorkspaceProfileSourceProvenance,
WorkspaceProfileSourceSummary, WorkspaceProfileSourceSummary,
WorkspaceProfileSummary, WorkspaceProfileSummary,
WorkspacePublicIdentityBundle,
WorkspaceSigningIdentityPublic,
WorkspaceSigningIdentityResponse,
WorkspaceSigningIdentityState,
} from "$lib/generated/workspace-api"; } from "$lib/generated/workspace-api";
export class ProfileApiError extends Error { export class ProfileApiError extends Error {
@@ -51,6 +55,18 @@ function stringValue(value: unknown, context: string): string {
return value; return value;
} }
function boundedStringValue(
value: unknown,
context: string,
maxBytes: number,
): string {
const text = stringValue(value, context);
if (new TextEncoder().encode(text).byteLength > maxBytes) {
throw new ProfileApiError(`${context} returned an invalid response.`, 502);
}
return text;
}
function booleanValue(value: unknown, context: string): boolean { function booleanValue(value: unknown, context: string): boolean {
if (typeof value !== "boolean") { if (typeof value !== "boolean") {
throw new ProfileApiError(`${context} returned an invalid response.`, 502); throw new ProfileApiError(`${context} returned an invalid response.`, 502);
@@ -66,6 +82,15 @@ function optionalString(
return stringValue(value, context); return stringValue(value, context);
} }
function optionalBoundedString(
value: unknown,
context: string,
maxBytes: number,
): string | null | undefined {
if (value === undefined || value === null) return value;
return boundedStringValue(value, context, maxBytes);
}
function optionalRevision( function optionalRevision(
value: unknown, value: unknown,
context: string, context: string,
@@ -286,6 +311,197 @@ export function parseProfileSettingsResponse(
}; };
} }
export function parseWorkspaceSigningIdentityResponse(
value: unknown,
): WorkspaceSigningIdentityResponse {
const item = record(value, "Workspace signing identity");
exactKeys(
item,
["identity"],
["public_bundle"],
"Workspace signing identity",
);
const identityItem = record(item.identity, "Workspace signing identity");
exactKeys(
identityItem,
["workspace_id", "key_id", "algorithm", "revision", "state", "created_at"],
["public_key", "public_key_fingerprint", "provisioned_at"],
"Workspace signing identity",
);
const state = boundedStringValue(
identityItem.state,
"Workspace signing identity",
32,
);
if (state !== "pending_provisioning" && state !== "active") {
throw new ProfileApiError(
"Workspace signing identity returned an invalid response.",
502,
);
}
const revision = optionalRevision(
identityItem.revision,
"Workspace signing identity",
);
if (revision === undefined || revision === null || revision < 1) {
throw new ProfileApiError(
"Workspace signing identity returned an invalid response.",
502,
);
}
const publicKey = optionalBoundedString(
identityItem.public_key,
"Workspace signing identity",
256,
);
const fingerprint = optionalBoundedString(
identityItem.public_key_fingerprint,
"Workspace signing identity",
128,
);
const provisionedAt = optionalBoundedString(
identityItem.provisioned_at,
"Workspace signing identity",
128,
);
const identity: WorkspaceSigningIdentityPublic = {
workspace_id: boundedStringValue(
identityItem.workspace_id,
"Workspace signing identity",
128,
),
key_id: boundedStringValue(
identityItem.key_id,
"Workspace signing identity",
128,
),
algorithm: boundedStringValue(
identityItem.algorithm,
"Workspace signing identity",
32,
),
...(publicKey === undefined || publicKey === null
? {}
: { public_key: publicKey }),
...(fingerprint === undefined || fingerprint === null
? {}
: { public_key_fingerprint: fingerprint }),
revision,
state: state as WorkspaceSigningIdentityState,
created_at: boundedStringValue(
identityItem.created_at,
"Workspace signing identity",
128,
),
...(provisionedAt === undefined || provisionedAt === null
? {}
: { provisioned_at: provisionedAt }),
};
let publicBundle: WorkspacePublicIdentityBundle | undefined;
if (item.public_bundle !== undefined) {
const bundle = record(
item.public_bundle,
"Workspace public identity bundle",
);
exactKeys(
bundle,
[
"workspace_id",
"backend_url",
"key_id",
"algorithm",
"public_key",
"public_key_fingerprint",
"revision",
],
[],
"Workspace public identity bundle",
);
const bundleRevision = optionalRevision(
bundle.revision,
"Workspace public identity bundle",
);
if (
bundleRevision === undefined || bundleRevision === null ||
bundleRevision < 1
) {
throw new ProfileApiError(
"Workspace public identity bundle returned an invalid response.",
502,
);
}
publicBundle = {
workspace_id: boundedStringValue(
bundle.workspace_id,
"Workspace public identity bundle",
128,
),
backend_url: boundedStringValue(
bundle.backend_url,
"Workspace public identity bundle",
2048,
),
key_id: boundedStringValue(
bundle.key_id,
"Workspace public identity bundle",
128,
),
algorithm: boundedStringValue(
bundle.algorithm,
"Workspace public identity bundle",
32,
),
public_key: boundedStringValue(
bundle.public_key,
"Workspace public identity bundle",
256,
),
public_key_fingerprint: boundedStringValue(
bundle.public_key_fingerprint,
"Workspace public identity bundle",
128,
),
revision: bundleRevision,
};
}
if (
publicBundle !== undefined &&
(
publicBundle.workspace_id !== identity.workspace_id ||
publicBundle.key_id !== identity.key_id ||
publicBundle.algorithm !== identity.algorithm ||
publicBundle.public_key !== identity.public_key ||
publicBundle.public_key_fingerprint !== identity.public_key_fingerprint ||
publicBundle.revision !== identity.revision
)
) {
throw new ProfileApiError(
"Workspace public identity bundle does not match identity metadata.",
502,
);
}
if (
(state === "active" &&
(publicBundle === undefined || identity.public_key === undefined ||
identity.public_key_fingerprint === undefined ||
identity.provisioned_at === undefined)) ||
(state === "pending_provisioning" &&
(publicBundle !== undefined || identity.public_key !== undefined ||
identity.public_key_fingerprint !== undefined ||
identity.provisioned_at !== undefined))
) {
throw new ProfileApiError(
"Workspace signing identity returned an invalid response.",
502,
);
}
return {
identity,
...(publicBundle === undefined ? {} : { public_bundle: publicBundle }),
};
}
async function parseResponse<T>( async function parseResponse<T>(
response: Response, response: Response,
parser: (value: unknown) => T, parser: (value: unknown) => T,
@@ -303,7 +519,7 @@ export async function fetchWorkspaceMetadata(
workspaceId: string, workspaceId: string,
): Promise<WorkspaceMetadataSettingsResponse> { ): Promise<WorkspaceMetadataSettingsResponse> {
return await parseResponse( return await parseResponse(
await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`), await fetch(`/api/w/${encodeURIComponent(workspaceId)}/settings`),
parseWorkspaceMetadataSettingsResponse, parseWorkspaceMetadataSettingsResponse,
); );
} }
@@ -314,7 +530,7 @@ export async function updateWorkspaceMetadata(
): Promise<WorkspaceMetadataMutationResponse> { ): Promise<WorkspaceMetadataMutationResponse> {
return await parseResponse( return await parseResponse(
await fetch( await fetch(
`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, `/api/w/${encodeURIComponent(workspaceId)}/settings`,
{ {
method: "PUT", method: "PUT",
headers: { "content-type": "application/json" }, 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( export async function fetchProfileSettings(
workspaceId: string, workspaceId: string,
): Promise<ProfileSettingsResponse> { ): Promise<ProfileSettingsResponse> {
@@ -20,10 +20,10 @@ function workdir(
repository_key: "repository-1", repository_key: "repository-1",
current_selector, current_selector,
current_ref, current_ref,
materializer_kind: "local_git_worktree", materializer_kind: "runtime_git_clone",
status: "active", status: "active",
cleanup_target: { cleanup_target: {
kind: "local_git_worktree", kind: "runtime_git_clone",
working_directory_id: "workdir-1", working_directory_id: "workdir-1",
repository_key: "repository-1", repository_key: "repository-1",
}, },
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { workspaceRoute } from '$lib/workspace/api/http'; import { workspaceRoute } from '$lib/workspace/api/http';
import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model'; import { SETTINGS_SECTIONS, settingsSectionHref } from '$lib/workspace/settings/model';
import type { SettingsSectionId } from '$lib/workspace/settings/model';
import type { SidebarSnippet } from './context'; import type { SidebarSnippet } from './context';
let { let {
@@ -17,8 +18,9 @@
return workspaceId ? workspaceRoute(workspaceId, path) : path; return workspaceId ? workspaceRoute(workspaceId, path) : path;
} }
function isActive(href: string): boolean { function isActive(href: string, sectionId: SettingsSectionId): boolean {
return currentPath === href || currentPath.startsWith(`${href}/`); return currentPath === href ||
(sectionId !== 'workspace-identity' && currentPath.startsWith(`${href}/`));
} }
</script> </script>
@@ -32,10 +34,10 @@
{#each SETTINGS_SECTIONS as section} {#each SETTINGS_SECTIONS as section}
{@const href = sectionHref(settingsSectionHref(section.id))} {@const href = sectionHref(settingsSectionHref(section.id))}
<a <a
class:active={isActive(href)} class:active={isActive(href, section.id)}
class="sidebar-link" class="sidebar-link"
href={href} href={href}
aria-current={isActive(href) ? 'page' : undefined} aria-current={isActive(href, section.id) ? 'page' : undefined}
> >
<span class="sidebar-link-label">{section.label}</span> <span class="sidebar-link-label">{section.label}</span>
</a> </a>
@@ -54,12 +54,12 @@ const options: WorkerLaunchOptionsResponse = {
creation_ref: "0123456789abcdef", creation_ref: "0123456789abcdef",
current_selector: null, current_selector: null,
current_ref: "0123456789abcdef", current_ref: "0123456789abcdef",
materializer_kind: "local_git_worktree", materializer_kind: "runtime_git_clone",
status: "active", status: "active",
cleanliness: "clean", cleanliness: "clean",
primary_worker_id: null, primary_worker_id: null,
cleanup_target: { cleanup_target: {
kind: "git_worktree", kind: "runtime_git_clone",
working_directory_id: "wd-1-repo", working_directory_id: "wd-1-repo",
repository_key: "repo", repository_key: "repo",
}, },
@@ -5,7 +5,12 @@ export function liveWorkerState(worker: {
worker_state?: WorkerStateSnapshot | null; worker_state?: WorkerStateSnapshot | null;
}): string { }): string {
const state = worker.worker_state?.state; 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.kind === "idle") return "idle";
if (state.state.kind === "maintenance") return "running"; if (state.state.kind === "maintenance") return "running";
return state.state.state === "paused" ? "paused" : "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); const unavailable = worker('runtime-a', 'worker-2', 1);
assertEquals(liveWorkerState(unavailable), 'unknown'); assertEquals(liveWorkerState(unavailable), 'unknown');
assertEquals(
liveWorkerState({ ...unavailable, state: 'missing' }),
'missing',
);
unavailable.state = 'stopped'; unavailable.state = 'stopped';
assertEquals(liveWorkerState(unavailable), 'stopped'); assertEquals(liveWorkerState(unavailable), 'stopped');
}); });
@@ -1,8 +1,88 @@
<script lang="ts"> <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'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); 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> </script>
<svelte:head> <svelte:head>
@@ -78,6 +158,47 @@
{/if} {/if}
</section> </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"> <section class="card repository-log-card">
<h2>Recent commits</h2> <h2>Recent commits</h2>
{#if data.repositoryLog} {#if data.repositoryLog}
@@ -1,4 +1,5 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
import { import {
parseRepositoryDetailResponse, parseRepositoryDetailResponse,
parseRepositoryLogResponse, parseRepositoryLogResponse,
@@ -8,7 +9,7 @@ import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
const workspaceId = params.workspaceId; const workspaceId = params.workspaceId;
const repositoryKey = params.repositoryKey; const repositoryKey = params.repositoryKey;
const [repositoryResult, logResult] = await Promise.all([ const [repositoryResult, logResult, runtimesResult] = await Promise.all([
loadJson<unknown>( loadJson<unknown>(
fetch, fetch,
workspaceApiPath( workspaceApiPath(
@@ -23,6 +24,10 @@ export const load: PageLoad = async ({ fetch, params }) => {
`/repositories/${encodeURIComponent(repositoryKey)}/log`, `/repositories/${encodeURIComponent(repositoryKey)}/log`,
), ),
), ),
loadJson<unknown>(
fetch,
workspaceApiPath(workspaceId, "/runtimes"),
),
]); ]);
let repository = null; 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 { return {
repositoryKey, repositoryKey,
repository, repository,
repositoryError, repositoryError,
repositoryLog: log, repositoryLog: log,
repositoryLogError: logError, 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, CreateRepositorySshCredentialRequest,
DeleteRepositorySshCredentialRequest, DeleteRepositorySshCredentialRequest,
DeleteRepositorySshHostTrustRequest, DeleteRepositorySshHostTrustRequest,
GenerateRepositorySshCredentialRequest,
PutRepositorySshHostTrustRequest, PutRepositorySshHostTrustRequest,
RepositorySshCredential, RepositorySshCredential,
RepositorySshHostTrust, RepositorySshHostTrust,
RepositorySshPublicKey,
RotateRepositorySshCredentialRequest, RotateRepositorySshCredentialRequest,
} from '$lib/generated/repository-access-api'; } from '$lib/generated/repository-access-api';
import { import {
parseRepositorySshCredential, parseRepositorySshCredential,
parseRepositorySshHostTrust, parseRepositorySshHostTrust,
parseRepositorySshPublicKey,
} from '$lib/workspace/api/repository-access'; } from '$lib/workspace/api/repository-access';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let credentials = $state<RepositorySshCredential[]>(untrack(() => data.credentials)); 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)); let hostTrusts = $state<RepositorySshHostTrust[]>(untrack(() => data.hostTrusts));
const accessProjection = untrack(() => data.accessProjection); const accessProjection = untrack(() => data.accessProjection);
let message = $state<string | null>(null); let message = $state<string | null>(null);
let pending = $state(false); let pending = $state(false);
let copiedCredentialId = $state<string | null>(null);
let generateCredentialId = $state('');
let generateCredentialName = $state('');
let credentialId = $state(''); let credentialId = $state('');
let credentialName = $state(''); let credentialName = $state('');
let privateKey = $state(''); let privateKey = $state('');
@@ -37,6 +46,7 @@
let hostExpectedRevision = $state<number | null>(null); let hostExpectedRevision = $state<number | null>(null);
const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`); const base = $derived(`/api/w/${encodeURIComponent(data.workspaceId)}/settings/repository-access`);
const workspaceDefaultCredentialId = 'workspace-default';
function operationId(prefix: string): string { function operationId(prefix: string): string {
return `${prefix}-${crypto.randomUUID()}`; return `${prefix}-${crypto.randomUUID()}`;
@@ -71,6 +81,55 @@
return parse(payload); 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() { async function createCredential() {
pending = true; pending = true;
message = null; message = null;
@@ -88,7 +147,9 @@
body, body,
parseRepositorySshCredential parseRepositorySshCredential
); );
const publicKey = await loadPublicKey(created.credential_id);
credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id)); credentials = [...credentials, created].sort((a, b) => a.credential_id.localeCompare(b.credential_id));
publicKeys = { ...publicKeys, [created.credential_id]: publicKey };
credentialId = ''; credentialId = '';
credentialName = ''; credentialName = '';
message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`; message = `Credential ${created.credential_id} created. Pasted secret fields were cleared.`;
@@ -117,7 +178,9 @@
body, body,
parseRepositorySshCredential parseRepositorySshCredential
); );
const publicKey = await loadPublicKey(rotated.credential_id);
credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry); credentials = credentials.map((entry) => entry.credential_id === rotated.credential_id ? rotated : entry);
publicKeys = { ...publicKeys, [rotated.credential_id]: publicKey };
rotateCredentialId = null; rotateCredentialId = null;
message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`; message = `Credential ${rotated.credential_id} rotated to revision ${rotated.current_revision}. Pasted secret fields were cleared.`;
} catch (error) { } catch (error) {
@@ -145,6 +208,9 @@
null null
); );
credentials = credentials.filter((entry) => entry.credential_id !== credential.credential_id); 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.`; message = `Credential ${credential.credential_id} deleted.`;
} catch (error) { } catch (error) {
message = error instanceof Error ? error.message : 'Credential deletion failed'; 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> <div><p class="eyebrow">owner only</p><h2>Repository Access</h2></div>
<span class="badge success">encrypted</span> <span class="badge success">encrypted</span>
</header> </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} {#if message}<p class="status-message">{message}</p>{/if}
<div class="settings-runtime-list"> <div class="settings-runtime-list">
@@ -237,7 +303,7 @@
{#each accessProjection.bindings as binding (binding.repository_key)} {#each accessProjection.bindings as binding (binding.repository_key)}
<div class="card"> <div class="card">
<strong>{binding.repository_key}</strong> <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> </div>
{/each} {/each}
</div> </div>
@@ -248,12 +314,19 @@
{#each credentials as credential (credential.credential_id)} {#each credentials as credential (credential.credential_id)}
<div class="card"> <div class="card">
<strong>{credential.name}</strong> <code>{credential.credential_id}</code> <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>{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]}
<div class="settings-action-row"> <label><span>Public key</span><textarea readonly rows="3" value={publicKeys[credential.credential_id].public_key}></textarea></label>
<button type="button" onclick={() => (rotateCredentialId = rotateCredentialId === credential.credential_id ? null : credential.credential_id)}>Rotate</button> <button type="button" onclick={() => void copyPublicKey(credential.credential_id)}>{copiedCredentialId === credential.credential_id ? 'Copied' : 'Copy public key'}</button>
<button type="button" class="danger" disabled={pending || credential.referenced_repositories.length > 0} onclick={() => void deleteCredential(credential)}>Delete</button> {/if}
</div> <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} {#if rotateCredentialId === credential.credential_id}
<form class="settings-runtime-form" onsubmit={(event) => { event.preventDefault(); void rotateCredential(credential); }}> <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> <label><span>New private key</span><textarea bind:value={rotatePrivateKey} required rows="8" autocomplete="off"></textarea></label>
@@ -264,8 +337,16 @@
</div> </div>
{/each} {/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(); }}> <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>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>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> <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, parseRepositoryAccessProjection,
parseRepositorySshCredentials, parseRepositorySshCredentials,
parseRepositorySshHostTrusts, parseRepositorySshHostTrusts,
parseRepositorySshPublicKey,
} from "$lib/workspace/api/repository-access"; } from "$lib/workspace/api/repository-access";
import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader"; import { loadRepositoryAccessJson } from "$lib/workspace/api/repository-access-loader";
import type { PageLoad } from "./$types"; 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 { return {
workspaceId, workspaceId,
credentials, credentials,
publicKeys,
hostTrusts, hostTrusts,
accessProjection, accessProjection,
}; };
@@ -2,20 +2,32 @@
import { invalidateAll } from '$app/navigation'; import { invalidateAll } from '$app/navigation';
import type { import type {
RuntimeConnectionTestResponse, RuntimeConnectionTestResponse,
RuntimePublicIdentityBundle,
WorkspaceRuntimeResource, WorkspaceRuntimeResource,
} from '$lib/generated/workspace-api'; } from '$lib/generated/workspace-api';
import {
createRemoteRuntime,
previewRuntimePublicKeyFingerprint,
RuntimeTrustRequestError,
} from '$lib/workspace/api/runtime-management';
import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection'; import { testRuntimeConnection } from '$lib/workspace/api/runtime-connection';
import { workspaceApiPath } from '$lib/workspace/api/http'; import { provisionWorkspaceSigningIdentity } from '$lib/workspace/settings/profile-api';
import type { PageProps } from './$types'; import type { PageProps } from './$types';
const runtimeBundlePlaceholder =
'{"identity_id":"team-runtime","public_key":"yoi-ed25519-pub:v1:..."}';
let { data }: PageProps = $props(); let { data }: PageProps = $props();
let runtimeId = $state(''); let runtimePublicBundle = $state('');
let displayName = $state(''); let displayName = $state('');
let endpoint = $state(''); let endpoint = $state('');
let runtimeFingerprint = $state<string | null>(null);
let showAddRuntime = $state(false); let showAddRuntime = $state(false);
let busyRuntimeId = $state<string | null>(null); let busyRuntimeId = $state<string | null>(null);
let requestError = $state<string | null>(null); let requestError = $state<string | null>(null);
let requestNotice = $state<string | null>(null);
let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({}); let testResults = $state<Record<string, RuntimeConnectionTestResponse>>({});
let connectionTestGeneration = 0;
function runtimePlatform(runtime: WorkspaceRuntimeResource): string { function runtimePlatform(runtime: WorkspaceRuntimeResource): string {
return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown'; return runtime.os && runtime.arch ? `${runtime.os} / ${runtime.arch}` : 'Unknown';
@@ -23,7 +35,9 @@
function connectionTestSummary(result: RuntimeConnectionTestResponse): string { function connectionTestSummary(result: RuntimeConnectionTestResponse): string {
if (result.status === 'compatible') { if (result.status === 'compatible') {
return `Compatible · protocol v${result.actual_protocol_version}`; return result.connection_state === 'verified'
? `Verified · protocol v${result.actual_protocol_version}`
: `Compatible · ${result.connection_state} · protocol v${result.actual_protocol_version}`;
} }
switch (result.failure_kind) { switch (result.failure_kind) {
case 'authentication': return 'Authentication failed'; case 'authentication': return 'Authentication failed';
@@ -46,33 +60,47 @@
return 'Observed'; return 'Observed';
} }
async function responseError(response: Response): Promise<string> { function parseRuntimePublicBundle(value: string): RuntimePublicIdentityBundle {
const payload = await response.json().catch(() => null) as let parsed: unknown;
| { message?: string; error?: string } try {
| null; parsed = JSON.parse(value);
return payload?.message ?? payload?.error ?? `Request failed (${response.status})`; } catch {
throw new Error('Runtime public bundle must be valid JSON');
}
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Runtime public bundle must be a JSON object');
}
const item = parsed as Record<string, unknown>;
if (
Object.keys(item).length !== 2 ||
typeof item.identity_id !== 'string' ||
item.identity_id.length === 0 ||
typeof item.public_key !== 'string' ||
item.public_key.length === 0
) {
throw new Error('Runtime public bundle must contain only identity_id and public_key');
}
return { identity_id: item.identity_id, public_key: item.public_key };
} }
async function addRuntime(event: SubmitEvent): Promise<void> { function workspacePublicBundle(): string {
event.preventDefault(); 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; requestError = null;
busyRuntimeId = 'create'; requestNotice = null;
busyRuntimeId = 'provision-workspace-identity';
try { try {
const response = await fetch(workspaceApiPath(data.workspaceId, '/runtimes'), { await provisionWorkspaceSigningIdentity(data.workspaceId);
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
runtime_id: runtimeId,
display_name: displayName || null,
endpoint,
}),
});
if (!response.ok) throw new Error(await responseError(response));
runtimeId = '';
displayName = '';
endpoint = '';
showAddRuntime = false;
await invalidateAll(); await invalidateAll();
requestNotice = 'Workspace identity provisioned. Copy its public bundle to the Runtime host.';
} catch (error) { } catch (error) {
requestError = error instanceof Error ? error.message : String(error); requestError = error instanceof Error ? error.message : String(error);
} finally { } finally {
@@ -80,18 +108,98 @@
} }
} }
async function testRuntime(runtime: WorkspaceRuntimeResource): Promise<void> { async function copyWorkspaceBundle(): Promise<void> {
requestError = null; requestError = null;
busyRuntimeId = runtime.runtime_id;
try { try {
const result = await testRuntimeConnection(data.workspaceId, runtime.runtime_id); await navigator.clipboard.writeText(workspacePublicBundle());
testResults = { ...testResults, [runtime.runtime_id]: result }; } 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) { } catch (error) {
requestError = error instanceof Error ? error.message : String(error); requestError = error instanceof Error ? error.message : String(error);
} finally { } finally {
busyRuntimeId = null; busyRuntimeId = null;
} }
} }
async function addRuntime(event: SubmitEvent): Promise<void> {
event.preventDefault();
requestError = null;
requestNotice = null;
busyRuntimeId = 'create';
try {
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,
});
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 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> </script>
<svelte:head> <svelte:head>
@@ -114,23 +222,107 @@
{#if showAddRuntime && data.workspace.permissions.manage_runtimes} {#if showAddRuntime && data.workspace.permissions.manage_runtimes}
<form class="settings-runtime-form" onsubmit={addRuntime}> <form class="settings-runtime-form" onsubmit={addRuntime}>
<h2>Add remote Runtime</h2> <header>
<div class="settings-form-grid"> <h2>Connect a remote Runtime</h2>
<label> <p>
Runtime ID This creates a binding for this Workspace. The Runtime can remain connected to other Workspaces;
<input bind:value={runtimeId} required autocomplete="off" /> their trust entries are not replaced.
</label> </p>
<label> </header>
Display name
<input bind:value={displayName} autocomplete="off" /> <section class="settings-runtime-trust-instructions" aria-labelledby="workspace-to-runtime-heading">
</label> <h3 id="workspace-to-runtime-heading">1. Trust this Workspace on the Runtime</h3>
<label> <p>
Endpoint Each Workspace has its own signing identity. Add this Workspace public bundle to the same store used
<input bind:value={endpoint} type="url" required placeholder="https://runtime.example" /> when starting the Runtime.
</label> </p>
</div> {#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 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" />
</label>
<label>
Endpoint
<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"> <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}> <button type="button" disabled={busyRuntimeId !== null} onclick={() => showAddRuntime = false}>
Cancel Cancel
</button> </button>
@@ -141,6 +333,9 @@
{#if requestError} {#if requestError}
<p class="section-state error">{requestError}</p> <p class="section-state error">{requestError}</p>
{/if} {/if}
{#if requestNotice}
<p class="section-state">{requestNotice}</p>
{/if}
{#if data.runtimesError} {#if data.runtimesError}
<p class="section-state error">{data.runtimesError}</p> <p class="section-state error">{data.runtimesError}</p>
@@ -174,7 +369,9 @@
<small><code>{runtime.runtime_id}</code></small> <small><code>{runtime.runtime_id}</code></small>
</td> </td>
<td>{runtime.kind}</td> <td>{runtime.kind}</td>
<td>{runtime.status}</td> <td>
{runtime.management?.binding?.connection_state ?? runtime.status}
</td>
<td>{runtimePlatform(runtime)}</td> <td>{runtimePlatform(runtime)}</td>
<td>{managementLabel(runtime)}</td> <td>{managementLabel(runtime)}</td>
<td> <td>
@@ -184,20 +381,23 @@
</td> </td>
<td> <td>
<div class="settings-action-row"> <div class="settings-action-row">
{#if runtime.management?.config_managed} {#if runtime.management?.config_managed && runtime.management.binding?.connection_state !== 'revoked'}
<button <button
type="button" type="button"
disabled={busyRuntimeId !== null} disabled={busyRuntimeId !== null}
onclick={() => testRuntime(runtime)} onclick={() => testRuntime(runtime)}
>Test</button> >Test</button>
{/if} {/if}
{#if !runtime.management?.config_managed} {#if runtime.management?.binding?.state === 'configured'}
<span class="settings-muted-action">Verification required</span>
{:else if !runtime.management?.config_managed}
<span class="settings-muted-action">Test unavailable</span> <span class="settings-muted-action">Test unavailable</span>
{/if} {/if}
</div> </div>
</td> </td>
</tr> </tr>
{#if runtime.diagnostics.length > 0 || testResults[runtime.runtime_id]} {@const currentResult = currentTestResult(runtime)}
{#if runtime.diagnostics.length > 0 || currentResult}
<tr class="settings-runtime-detail-row"> <tr class="settings-runtime-detail-row">
<td colspan="7"> <td colspan="7">
{#if runtime.diagnostics.length > 0} {#if runtime.diagnostics.length > 0}
@@ -210,14 +410,13 @@
{/each} {/each}
</ul> </ul>
{/if} {/if}
{#if testResults[runtime.runtime_id]} {#if currentResult}
{@const result = testResults[runtime.runtime_id]} <div class:failed={currentResult.status === 'failed'} class="settings-test-result">
<div class:failed={result.status === 'failed'} class="settings-test-result"> <strong>Connection test: {connectionTestSummary(currentResult)}</strong>
<strong>Connection test: {connectionTestSummary(result)}</strong> {#if currentResult.diagnostics[0]}
{#if result.diagnostics[0]} <span>{currentResult.diagnostics[0].message}</span>
<span>{result.diagnostics[0].message}</span>
{/if} {/if}
<small>Checked {new Date(result.checked_at).toLocaleString()}</small> <small>Checked {new Date(currentResult.checked_at).toLocaleString()}</small>
</div> </div>
{/if} {/if}
</td> </td>
@@ -1,5 +1,6 @@
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management"; import { parseWorkspaceRuntimeList } from "$lib/workspace/api/runtime-management";
import { parseWorkspaceSigningIdentityResponse } from "$lib/workspace/settings/profile-api";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = async ({ fetch, params }) => { export const load: PageLoad = async ({ fetch, params }) => {
@@ -16,9 +17,24 @@ export const load: PageLoad = async ({ fetch, params }) => {
}, },
); );
const signingIdentity = await loadJson(
fetch,
workspaceApiPath(params.workspaceId, "/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 { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
runtimes: runtimes.data, runtimes: runtimes.data,
runtimesError: runtimes.error, runtimesError: runtimes.error,
signingIdentity: signingIdentity.data,
signingIdentityError: signingIdentity.error,
}; };
}; };
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from '$app/navigation'; import { goto, invalidateAll } from '$app/navigation';
import type { import type {
PutRuntimeTrustKeyRequest,
RevokeRuntimeTrustKeyRequest, RevokeRuntimeTrustKeyRequest,
RuntimeTrustKeyStatus, RuntimeTrustKeyStatus,
} from '$lib/generated/workspace-api'; } from '$lib/generated/workspace-api';
import { import {
createRemoteRuntime,
deleteRemoteRuntime,
previewRuntimePublicKeyFingerprint, previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey,
revealRuntimeTrustKey, revealRuntimeTrustKey,
revokeRuntimeTrustKey, revokeRuntimeTrustKey,
RuntimeTrustConflictError, RuntimeTrustConflictError,
@@ -23,10 +23,10 @@
let showPublicKey = $state(false); let showPublicKey = $state(false);
let revealedPublicKey = $state<string | null>(null); let revealedPublicKey = $state<string | null>(null);
let publicKey = $state(''); let publicKey = $state('');
let fingerprintConfirmation = $state(''); let deleteRuntimeConfirmation = $state('');
let revokeFingerprintConfirmation = $state(''); let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | 'delete' | null>(null);
let busyAction = $state<'save' | 'revoke' | 'reveal' | 'copy' | null>(null);
let fieldError = $state<string | null>(null); let fieldError = $state<string | null>(null);
let deleteRuntimeError = $state<string | null>(null);
let requestError = $state<string | null>(null); let requestError = $state<string | null>(null);
let successMessage = $state<string | null>(null); let successMessage = $state<string | null>(null);
let replacementFingerprint = $state<string | null>(null); let replacementFingerprint = $state<string | null>(null);
@@ -43,10 +43,10 @@
showPublicKey = false; showPublicKey = false;
revealedPublicKey = null; revealedPublicKey = null;
publicKey = ''; publicKey = '';
fingerprintConfirmation = ''; deleteRuntimeConfirmation = '';
revokeFingerprintConfirmation = '';
busyAction = null; busyAction = null;
fieldError = null; fieldError = null;
deleteRuntimeError = null;
requestError = null; requestError = null;
successMessage = null; successMessage = null;
replacementFingerprint = null; replacementFingerprint = null;
@@ -133,30 +133,27 @@
const trust = data.runtimeDetail.trust_key; const trust = data.runtimeDetail.trust_key;
const action = trustAction(trust.status); 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); const operation = routeFence.capture(data.runtimeId);
busyAction = 'save'; busyAction = 'save';
try { try {
await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request); const binding = data.runtimeDetail.runtime.management.binding;
if (!binding || !data.runtimeDetail.endpoint) {
throw new RuntimeTrustRequestError(
'The Workspace identity binding and authoritative Runtime endpoint are required.',
);
}
await createRemoteRuntime(data.workspaceId, {
public_bundle: {
identity_id: operation.runtimeId,
public_key: key,
},
display_name: data.runtimeDetail.runtime.label,
endpoint: data.runtimeDetail.endpoint,
expected_revision: binding.revision,
});
if (!isCurrentRoute(operation)) return; if (!isCurrentRoute(operation)) return;
publicKey = ''; publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
showPublicKey = false; showPublicKey = false;
revealedPublicKey = null; revealedPublicKey = null;
successMessage = action === 'create' successMessage = action === 'create'
@@ -167,7 +164,6 @@
await reloadAuthority(); await reloadAuthority();
} catch (error) { } catch (error) {
if (!isCurrentRoute(operation)) return; if (!isCurrentRoute(operation)) return;
fingerprintConfirmation = '';
if (error instanceof RuntimeTrustConflictError) { if (error instanceof RuntimeTrustConflictError) {
requestError = `${error.message} Authoritative Runtime trust has been reloaded.`; requestError = `${error.message} Authoritative Runtime trust has been reloaded.`;
await reloadAuthority(); await reloadAuthority();
@@ -188,11 +184,8 @@
requestError = 'Only active Workspace trust can be revoked.'; requestError = 'Only active Workspace trust can be revoked.';
return; return;
} }
if ( if (!trust.fingerprint) {
!trust.fingerprint || requestError = 'The authoritative fingerprint is unavailable. Reload before revoking trust.';
revokeFingerprintConfirmation.trim() !== trust.fingerprint
) {
fieldError = 'Enter the current fingerprint exactly before revoking Workspace trust.';
return; return;
} }
@@ -211,12 +204,10 @@
operation.runtimeId, operation.runtimeId,
request, request,
trust.fingerprint, trust.fingerprint,
revokeFingerprintConfirmation, trust.fingerprint,
); );
if (!isCurrentRoute(operation)) return; if (!isCurrentRoute(operation)) return;
publicKey = ''; publicKey = '';
fingerprintConfirmation = '';
revokeFingerprintConfirmation = '';
showPublicKey = false; showPublicKey = false;
revealedPublicKey = null; revealedPublicKey = null;
successMessage = 'Workspace trust was revoked.'; 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> { async function togglePublicKeyReveal(): Promise<void> {
if (showPublicKey) { if (showPublicKey) {
showPublicKey = false; showPublicKey = false;
@@ -315,7 +348,12 @@
<div><dt>Kind</dt><dd>{runtime.kind}</dd></div> <div><dt>Kind</dt><dd>{runtime.kind}</dd></div>
<div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div> <div><dt>Endpoint</dt><dd>{detail.endpoint ?? 'Not configured'}</dd></div>
<div><dt>Status</dt><dd>{runtime.status}</dd></div> <div><dt>Status</dt><dd>{runtime.status}</dd></div>
<div><dt>Binding status</dt><dd>{trust.status}</dd></div> <div><dt>Connection state</dt><dd>{runtime.management.binding?.connection_state ?? 'Not configured'}</dd></div>
<div><dt>Workspace signing key</dt><dd><code>{runtime.management.binding?.workspace_key_id ?? '—'}</code></dd></div>
<div><dt>Verified</dt><dd>{formatTimestamp(runtime.management.binding?.verification?.verified_at)}</dd></div>
<div><dt>Verified binding revision</dt><dd>{runtime.management.binding?.verification?.binding_revision?.toString() ?? '—'}</dd></div>
<div><dt>Last verification check</dt><dd>{runtime.management.binding?.verification?.last_outcome ?? '—'} · {formatTimestamp(runtime.management.binding?.verification?.last_checked_at)}</dd></div>
<div><dt>Runtime key status</dt><dd>{trust.status}</dd></div>
<div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div> <div><dt>Fingerprint</dt><dd><code>{trust.fingerprint ?? '—'}</code></dd></div>
<div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div> <div><dt>Revision</dt><dd>{trust.revision?.toString() ?? '—'}</dd></div>
<div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div> <div><dt>Created</dt><dd>{formatTimestamp(trust.created_at)}</dd></div>
@@ -384,18 +422,6 @@
<p class="field-error">{replacementFingerprintError}</p> <p class="field-error">{replacementFingerprintError}</p>
{/if} {/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} {#if fieldError}
<p id="runtime-public-key-error" class="field-error">{fieldError}</p> <p id="runtime-public-key-error" class="field-error">{fieldError}</p>
{/if} {/if}
@@ -410,25 +436,11 @@
<div> <div>
<strong>Revoke Workspace trust</strong> <strong>Revoke Workspace trust</strong>
<p>Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.</p> <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> </div>
<button <button
type="button" type="button"
class="danger" class="danger"
disabled={ disabled={busyAction !== null || trust.status !== 'active'}
busyAction !== null ||
trust.status !== 'active' ||
revokeFingerprintConfirmation.trim() !== trust.fingerprint
}
onclick={revokeTrust} onclick={revokeTrust}
>{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button> >{busyAction === 'revoke' ? 'Revoking…' : 'Revoke trust'}</button>
</div> </div>
@@ -467,5 +479,45 @@
</div> </div>
{/if} {/if}
</section> </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} {/if}
</section> </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, parseRepositoryAccessProjection,
parseRepositorySshCredentials, parseRepositorySshCredentials,
parseRepositorySshHostTrusts, parseRepositorySshHostTrusts,
parseRepositorySshPublicKey,
RepositoryAccessSchemaError, RepositoryAccessSchemaError,
} from "../../src/lib/workspace/api/repository-access.ts"; } from "../../src/lib/workspace/api/repository-access.ts";
@@ -59,6 +60,22 @@ const hostTrust = {
Deno.test("Repository Access parsers accept generated response contracts", () => { Deno.test("Repository Access parsers accept generated response contracts", () => {
assertEquals(parseRepositorySshCredentials([credential]), [credential]); 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(parseRepositorySshHostTrusts([hostTrust]), [hostTrust]);
assertEquals( assertEquals(
parseRepositoryAccessProjection({ parseRepositoryAccessProjection({
@@ -28,6 +28,7 @@ test("Repository Access Web code consumes workspace-api generated DTOs", () => {
assert( assert(
loaderSource.includes("parseRepositorySshCredentials") && loaderSource.includes("parseRepositorySshCredentials") &&
loaderSource.includes("parseRepositorySshHostTrusts") && loaderSource.includes("parseRepositorySshHostTrusts") &&
loaderSource.includes("parseRepositorySshPublicKey") &&
loaderSource.includes("parseRepositoryAccessProjection"), loaderSource.includes("parseRepositoryAccessProjection"),
"loader should validate unknown JSON before exposing generated DTOs to Svelte", "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", () => { test("Repository credential submissions clear write-only fields in finally blocks", () => {
const createStart = source.indexOf("async function createCredential()"); const createStart = source.indexOf("async function createCredential()");
const rotateStart = source.indexOf("async function rotateCredential("); 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 { import {
fetchProfileSettings, fetchProfileSettings,
fetchWorkspaceMetadata, fetchWorkspaceMetadata,
fetchWorkspaceSigningIdentity,
parseProfileSettingsResponse, parseProfileSettingsResponse,
parseWorkspaceMetadataSettingsResponse, parseWorkspaceMetadataSettingsResponse,
parseWorkspaceSigningIdentityResponse,
ProfileApiError, ProfileApiError,
provisionWorkspaceSigningIdentity,
updateWorkspaceMetadata, updateWorkspaceMetadata,
} from "../src/lib/workspace/settings/profile-api.ts"; } from "../src/lib/workspace/settings/profile-api.ts";
@@ -126,8 +129,8 @@ Deno.test("workspace metadata requests use generated DTO shapes", async () => {
"workspace 1", "workspace 1",
); );
assertEquals(requests.map((request) => request.url), [ assertEquals(requests.map((request) => request.url), [
"/api/w/workspace%201/settings/workspace", "/api/w/workspace%201/settings",
"/api/w/workspace%201/settings/workspace", "/api/w/workspace%201/settings",
]); ]);
assertEquals(requests[1].init?.method, "PUT"); assertEquals(requests[1].init?.method, "PUT");
assertEquals( 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", () => { Deno.test("profile settings parser rejects missing, mistyped, stale, and invalid provenance fields", () => {
const missing = profileSettingsFixture(); const missing = profileSettingsFixture();
delete missing.profiles; 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", () => { Deno.test("workspace metadata parser rejects incomplete or stale response fields", () => {
assertThrows( assertThrows(
() => () =>
@@ -19,6 +19,9 @@ function compatibleResponse(): Record<string, unknown> {
return { return {
workspace_id: "workspace-a", workspace_id: "workspace-a",
runtime_id: "runtime-a", runtime_id: "runtime-a",
binding_revision: 3,
connection_state: "verified",
verification: null,
checked_at: "2026-09-01T12:00:00Z", checked_at: "2026-09-01T12:00:00Z",
status: "compatible", status: "compatible",
failure_kind: null, failure_kind: null,
@@ -57,6 +60,23 @@ Deno.test("runtime connection response rejects unknown fields and incoherent com
}), }),
null, null,
); );
assertEquals(
parseRuntimeConnectionTestResponse({
...compatibleResponse(),
verification: {
verified_at: "2026-09-01T12:00:00Z",
last_checked_at: "2026-09-01T12:00:01Z",
last_outcome: "verified",
binding_revision: 2,
workspace_key_id: "WK-a",
workspace_identity_revision: 1,
workspace_trust_generation: 1,
runtime_public_key_fingerprint: "sha256:runtime",
runtime_identity_revision: 1,
},
}),
null,
);
}); });
Deno.test("runtime connection response rejects unknown failure kinds and unbounded diagnostics", () => { Deno.test("runtime connection response rejects unknown failure kinds and unbounded diagnostics", () => {
@@ -27,6 +27,11 @@ Deno.test("Runtime Settings routes validate unknown JSON through the shared Runt
listLoader.includes("parseWorkspaceRuntimeList(value)"), listLoader.includes("parseWorkspaceRuntimeList(value)"),
"Runtime list loader should validate unknown JSON", "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( assert(
detailLoader.includes("parseWorkspaceRuntimeDetail(value)"), detailLoader.includes("parseWorkspaceRuntimeDetail(value)"),
"Runtime detail loader should validate unknown JSON", "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 () => { Deno.test("Runtime list links to canonical detail and has no inline delete action", async () => {
const page = await Deno.readTextFile( const page = await Deno.readTextFile(
new URL( new URL(
@@ -106,22 +153,26 @@ Deno.test("Runtime detail keeps trust controls owner-only and conflict-safe", as
"Create Workspace trust", "Create Workspace trust",
"Replace trusted key", "Replace trusted key",
"Reactivate with this key", "Reactivate with this key",
"Confirm current fingerprint",
"Revoke Workspace trust", "Revoke Workspace trust",
"Workspace trust only; this does not delete the Runtime process, Workers, or Workdirs.", "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", "RuntimeTrustConflictError",
"RuntimeTrustRouteFence", "RuntimeTrustRouteFence",
"routeFence.enter(data.runtimeId)", "routeFence.enter(data.runtimeId)",
"showPublicKey = false", "showPublicKey = false",
"revealedPublicKey = null", "revealedPublicKey = null",
"publicKey = ''", "publicKey = ''",
"fingerprintConfirmation = ''",
"revokeFingerprintConfirmation = ''",
"requestError = null", "requestError = null",
"successMessage = null", "successMessage = null",
"isCurrentRoute(operation)", "isCurrentRoute(operation)",
"revealRuntimeTrustKey", "revealRuntimeTrustKey",
"revokeFingerprintConfirmation.trim() !== trust.fingerprint",
"await reloadAuthority()", "await reloadAuthority()",
"busyAction !== null", "busyAction !== null",
"Workdirs", "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}`); 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 () => { Deno.test("Runtime detail uses flat sections instead of nested cards", async () => {
+105 -32
View File
@@ -3,12 +3,13 @@ declare const Deno: {
}; };
import { import {
createRemoteRuntime,
deleteRemoteRuntime,
parseRuntimeTrustConflict, parseRuntimeTrustConflict,
parseRuntimeTrustKeyRevealResponse, parseRuntimeTrustKeyRevealResponse,
parseWorkspaceRuntimeDetail, parseWorkspaceRuntimeDetail,
parseWorkspaceRuntimeList, parseWorkspaceRuntimeList,
previewRuntimePublicKeyFingerprint, previewRuntimePublicKeyFingerprint,
putRuntimeTrustKey,
revokeRuntimeTrustKey, revokeRuntimeTrustKey,
RuntimeTrustConflictError, RuntimeTrustConflictError,
RuntimeTrustRouteFence, RuntimeTrustRouteFence,
@@ -39,6 +40,24 @@ function runtime() {
removable: false, removable: false,
endpoint_configured: true, endpoint_configured: true,
token_ref_configured: false, token_ref_configured: false,
binding: {
state: "verified",
connection_state: "verified",
revision: 3,
workspace_key_id: "WK-1",
workspace_key_generation: 1,
verification: {
verified_at: "2026-09-01T13:00:00Z",
last_checked_at: "2026-09-01T13:00:00Z",
last_outcome: "verified",
binding_revision: 3,
workspace_key_id: "WK-1",
workspace_identity_revision: 1,
workspace_trust_generation: 1,
runtime_public_key_fingerprint: "SHA256:current",
runtime_identity_revision: 1,
},
},
}, },
runtime_id: "arcadia", runtime_id: "arcadia",
label: "Arcadia", label: "Arcadia",
@@ -95,6 +114,11 @@ Deno.test("Runtime list and detail parsers return generated Runtime DTO shapes",
"Runtime ID was not preserved", "Runtime ID was not preserved",
); );
assert(
list.items[0]?.management.binding?.state === "verified",
"binding state was not preserved",
);
const parsed = parseWorkspaceRuntimeDetail(detail()); const parsed = parseWorkspaceRuntimeDetail(detail());
assert( assert(
parsed.trust_key.revision === 3, parsed.trust_key.revision === 3,
@@ -106,6 +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", () => { Deno.test("Runtime validators reject unknown object keys and enum variants", () => {
assertThrows( assertThrows(
() => parseWorkspaceRuntimeDetail({ ...detail(), head_tree: "stale" }), () => 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"); 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 () => { Deno.test("Runtime route fence rejects a delayed reveal from the prior Runtime", async () => {
const fence = new RuntimeTrustRouteFence(); const fence = new RuntimeTrustRouteFence();
fence.enter("runtime-a"); 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 () => { Deno.test("Runtime create surfaces bounded Settings error details", async () => {
let sentBody: unknown = null; const fetchImpl = (() =>
const fetchImpl = ((_: RequestInfo | URL, init?: RequestInit) => { Promise.resolve(
sentBody = JSON.parse(String(init?.body)) as unknown;
return Promise.resolve(
new Response( new Response(
JSON.stringify({ JSON.stringify({
error: "stale_revision", error: "remote_runtime_endpoint_not_allowed",
message: "Runtime trust changed", details: "Runtime endpoint must use public https egress",
current_revision: 4,
current_fingerprint: "SHA256:new",
}), }),
{ status: 409, headers: { "content-type": "application/json" } }, { status: 400, headers: { "content-type": "application/json" } },
), ),
); )) as typeof fetch;
}) as typeof fetch;
try { try {
await putRuntimeTrustKey( await createRemoteRuntime(
"workspace-a", "workspace-a",
"arcadia", {
{ public_key: "ssh-ed25519 AAAA-new", expected_revision: 3 }, public_bundle: {
identity_id: "runtime-a",
public_key: "yoi-ed25519-pub:v1:test",
},
display_name: null,
endpoint: "https://runtime.example",
expected_revision: null,
},
fetchImpl, fetchImpl,
); );
throw new Error("expected mutation to reject"); throw new Error("expected create to reject");
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error);
assert( assert(
error instanceof RuntimeTrustConflictError, message === "Runtime endpoint must use public https egress",
"expected typed conflict", `unexpected create error: ${message}`,
);
assert(
error.conflict.current_revision === 4,
"authoritative revision was lost",
); );
} }
assert(
JSON.stringify(sentBody) ===
JSON.stringify({
public_key: "ssh-ed25519 AAAA-new",
expected_revision: 3,
}),
"request should serialize the generated bigint revision as a safe JSON integer",
);
}); });
+1 -1
View File
@@ -11,7 +11,7 @@ import {
const summary = { const summary = {
working_directory_id: "workdir-1", working_directory_id: "workdir-1",
repository_key: "main", repository_key: "main",
materializer_kind: "runtime_git_cache", materializer_kind: "runtime_git_clone",
status: "active", status: "active",
occupied_by: { occupied_by: {
runtime_id: "arcadia", 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 () => { Deno.test("Workspace settings exposes owner-gated typed destructive confirmation", async () => {
const source = await Deno.readTextFile( const source = await Deno.readTextFile(
new URL( new URL(
"../src/routes/w/[workspaceId]/settings/workspace/+page.svelte", "../src/routes/w/[workspaceId]/settings/+page.svelte",
import.meta.url, import.meta.url,
), ),
); );