From 352be1f4c7618d2a534d7c75264b6d3f03ae8d8d Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 19:45:57 +0900 Subject: [PATCH 01/18] ticket: hold profile settings for resource fetch --- .../artifacts/orchestration-plan.jsonl | 2 ++ .yoi/tickets/00001KX0DSMPT/item.md | 2 +- .yoi/tickets/00001KX0DSMPT/thread.md | 29 +++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 .yoi/tickets/00001KX0DSMPT/artifacts/orchestration-plan.jsonl diff --git a/.yoi/tickets/00001KX0DSMPT/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KX0DSMPT/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..7d83e691 --- /dev/null +++ b/.yoi/tickets/00001KX0DSMPT/artifacts/orchestration-plan.jsonl @@ -0,0 +1,2 @@ +{"id":"orch-plan-20260708-104512-1","ticket_id":"00001KX0DSMPT","kind":"after","related_ticket":"00001KX0G06VA","note":"This Ticket is queued while `00001KX0G06VA` is inprogress because both touch Workspace/Backend profile/resource launch surfaces (`workspace-server` settings/API/hosts/server and profile archive/resource fetch integration). Sequence after the active resource-fetch implementation to avoid conflicting public API and ProfileSourceArchive authority-boundary changes.","author":"orchestrator","at":"2026-07-08T10:45:12Z"} +{"id":"orch-plan-20260708-104530-2","ticket_id":"00001KX0DSMPT","kind":"waiting_capacity_note","note":"Queue review after Dashboard authorization. The declared dependency `00001KWZ5KERY` is closed, so that blocker is resolved. However `00001KX0G06VA` is currently inprogress on branch `work/00001KX0G06VA-resource-fetch-api` and changes the same ProfileSourceArchive/resource/workspace-server launch API surfaces this Ticket needs to build on. Leave this Ticket queued until the active resource-fetch branch is reviewed/merged or its API shape is otherwise decided; then re-route/start from the updated orchestration branch.","author":"orchestrator","at":"2026-07-08T10:45:30Z"} diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index fee576bf..a9bbc33d 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -2,7 +2,7 @@ title: 'Add Workspace settings API and pages for Workspace/Profile editing' state: 'queued' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T10:44:13Z' +updated_at: '2026-07-08T10:45:48Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index c39b023a..03d3bb12 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -30,4 +30,33 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: blocked_by_dependency_or_missing_authority + +Reason: +- Declared dependency `00001KWZ5KERY` is now closed, so the original Decodal/ProfileSourceArchive prerequisite is satisfied。 +- However `00001KX0G06VA` is currently `inprogress` on branch `work/00001KX0G06VA-resource-fetch-api`。 +- This Ticket’s Workspace/Profile editing API and pages need to build on the same profile/resource/workspace-server launch surfaces that `00001KX0G06VA` is actively changing(ProfileSourceArchive resource handling, workspace-server settings/API/hosts/server, Browser-facing profile launch configuration)。 +- Starting this Ticket now would create a high-conflict parallel branch and risks designing Profile editing against the wrong resource-fetch/public API boundary。 +- Therefore this routing pass leaves the Ticket queued and does not record `queued -> inprogress`, create a worktree, or spawn role Pods。 + +Evidence checked: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KX0DSMPT)`: depends_on `00001KWZ5KERY`; target is closed。 +- `TicketOrchestrationPlanQuery(00001KX0DSMPT)`: prior record 0 件だったため、今回 `after 00001KX0G06VA` と waiting-capacity note を記録。 +- `TicketList`: queued はこの Ticket 1件、inprogress は `00001KX0G06VA` 1件。 +- Orchestrator worktree git status: clean on `orchestration`。 + +Next action: +- `00001KX0G06VA` の implementation/review/merge outcome を待つ。 +- resource-fetch API shape が merge されたら、この Ticket を再 routing し、updated orchestration branch から start する。 + +Escalate if: +- 人間が `00001KX0G06VA` を中断してこの Ticket を先に実装する、または両者を同一 combined worktree で統合実装する方針に切り替えたい場合。 + --- From 57e96d3be8c7a66476ce2ac50e4f28027a3bfc26 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 20:18:38 +0900 Subject: [PATCH 02/18] feat: add backend resource fetch api --- Cargo.lock | 1 + crates/worker-runtime/Cargo.toml | 3 +- crates/worker-runtime/src/config_bundle.rs | 106 +++++++- crates/worker-runtime/src/http_server.rs | 2 + crates/worker-runtime/src/lib.rs | 1 + crates/worker-runtime/src/main.rs | 21 ++ crates/worker-runtime/src/profile_archive.rs | 2 +- crates/worker-runtime/src/resource.rs | 256 ++++++++++++++++++ crates/worker-runtime/src/runtime.rs | 1 + crates/worker-runtime/src/worker_backend.rs | 77 +++++- crates/workspace-server/src/companion.rs | 1 + crates/workspace-server/src/hosts.rs | 102 ++++++- crates/workspace-server/src/lib.rs | 1 + .../workspace-server/src/resource_broker.rs | 231 ++++++++++++++++ crates/workspace-server/src/server.rs | 93 ++++++- package.nix | 2 +- 16 files changed, 859 insertions(+), 41 deletions(-) create mode 100644 crates/worker-runtime/src/resource.rs create mode 100644 crates/workspace-server/src/resource_broker.rs diff --git a/Cargo.lock b/Cargo.lock index e23dd6c8..c90d9941 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5936,6 +5936,7 @@ dependencies = [ "llm-engine", "manifest", "protocol", + "reqwest", "serde", "serde_json", "session-store", diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index a010ed23..8cf715a3 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -14,7 +14,7 @@ required-features = ["ws-server", "fs-store"] [features] default = [] fs-store = [] -http-server = ["dep:axum", "dep:tower"] +http-server = ["dep:axum", "dep:tower", "dep:reqwest"] ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"] [dependencies] @@ -28,6 +28,7 @@ serde = { workspace = true, features = ["derive"] } session-store.workspace = true sha2.workspace = true serde_json.workspace = true +reqwest = { version = "0.13", optional = true, default-features = false, features = ["json", "rustls"] } tar.workspace = true thiserror = { workspace = true } tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } diff --git a/crates/worker-runtime/src/config_bundle.rs b/crates/worker-runtime/src/config_bundle.rs index beafad32..a098bca7 100644 --- a/crates/worker-runtime/src/config_bundle.rs +++ b/crates/worker-runtime/src/config_bundle.rs @@ -4,6 +4,7 @@ use crate::profile_archive::{ ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef, VerifiedProfileSourceArchive, }; +use crate::resource::{BackendResourceHandle, validate_resource_handle_text}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -24,6 +25,8 @@ pub struct ConfigBundle { pub declarations: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub profile_source_archive: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_archive_handle: Option, } impl ConfigBundle { @@ -75,6 +78,27 @@ impl ConfigBundle { lines.push(format!("profile_archive_entrypoint\0{selector}\0{path}")); } } + if let Some(handle) = &self.profile_source_archive_handle { + lines.push(format!( + "profile_archive_handle\0{}\0{}\0{}\0{}\0{}", + handle.workspace_id, + handle.resource_id, + handle.digest, + handle.revision, + handle.max_bytes + )); + for (selector, path) in handle + .profile_source_graph + .as_ref() + .map(|graph| &graph.entrypoints) + .into_iter() + .flatten() + { + lines.push(format!( + "profile_archive_handle_entrypoint\0{selector}\0{path}" + )); + } + } lines.sort(); let mut hasher = Sha256::new(); @@ -105,7 +129,12 @@ impl ConfigBundle { profile_source_archive: self .profile_source_archive .as_ref() - .map(|archive| archive.reference.source_graph.clone()), + .map(|archive| archive.reference.source_graph.clone()) + .or_else(|| { + self.profile_source_archive_handle + .as_ref() + .and_then(|handle| handle.profile_source_graph.clone()) + }), } } @@ -253,6 +282,33 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}")) })?; } + if let Some(handle) = &bundle.profile_source_archive_handle { + for (label, value) in [ + ("resource handle workspace id", handle.workspace_id.as_str()), + ("resource handle resource id", handle.resource_id.as_str()), + ("resource handle digest", handle.digest.as_str()), + ("resource handle nonce", handle.nonce.as_str()), + ("resource handle revision", handle.revision.as_str()), + ("resource handle content type", handle.content_type.as_str()), + ( + "resource handle audit correlation id", + handle.audit_correlation_id.as_str(), + ), + ] { + validate_resource_handle_text(label, value).map_err(RuntimeError::InvalidRequest)?; + } + if !handle.digest.starts_with("sha256:") { + return Err(RuntimeError::InvalidRequest( + "resource handle digest must use sha256:".to_string(), + )); + } + if handle.profile_source_graph.is_none() { + return Err(RuntimeError::InvalidRequest( + "profile source archive resource handle must include source graph summary" + .to_string(), + )); + } + } Ok(()) } @@ -529,6 +585,7 @@ mod tests { reference: reference.to_string(), }], profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } @@ -560,6 +617,53 @@ mod tests { validate_config_bundle(&bundle_with_declaration("vault:team.api-key")).unwrap(); } + #[test] + fn bundle_summary_redacts_runtime_internal_resource_handle() { + let mut bundle = bundle_with_declaration("secret:github-token"); + let source_graph = crate::profile_archive::ProfileSourceGraphSummary { + entrypoints: std::collections::BTreeMap::from([( + "default".to_string(), + "profiles/default.dcdl".to_string(), + )]), + source_count: 1, + import_count: 0, + total_source_bytes: 16, + }; + bundle.profile_source_archive_handle = Some(crate::resource::BackendResourceHandle { + kind: crate::resource::BackendResourceKind::ProfileSourceArchive, + workspace_id: "workspace-1".to_string(), + scope_id: Some("scope-1".to_string()), + runtime_id: Some("runtime-1".to_string()), + worker_id: Some("worker-1".to_string()), + resource_id: "profile-source-archive:test".to_string(), + digest: "sha256:0123456789abcdef".to_string(), + operation: crate::resource::BackendResourceOperation::FetchArchive, + expires_at_unix_seconds: 4_102_444_800, + nonce: "nonce-not-for-browser".to_string(), + revision: "rev-1".to_string(), + generation: Some(1), + max_bytes: 128, + content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly, + audit_correlation_id: "audit-1".to_string(), + profile_source_graph: Some(source_graph), + }); + let rendered = serde_json::to_string(&bundle.summary()).unwrap(); + assert!(rendered.contains("profile_source_archive")); + for forbidden in [ + "nonce-not-for-browser", + "profile-source-archive:test", + "sha256:0123456789abcdef", + "runtime-1", + "worker-1", + ] { + assert!( + !rendered.contains(forbidden), + "leaked {forbidden}: {rendered}" + ); + } + } + #[test] fn rejects_unsafe_bundle_ids_and_refs() { for id in ["bundle/1", "bundle?x", "bundle&x", "bundle#x", " bundle"] { diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index e01dc9b3..f73f2b16 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -850,6 +850,7 @@ mod tests { }], declarations: Vec::new(), profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } @@ -1137,6 +1138,7 @@ mod ws_tests { }], declarations: Vec::new(), profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index 405aab74..c897f79c 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -20,6 +20,7 @@ pub mod interaction; pub mod management; pub mod observation; pub mod profile_archive; +pub mod resource; mod runtime; pub mod worker_backend; pub mod working_directory; diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index 54112219..1f0f723c 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -78,6 +78,14 @@ fn build_runtime(config: &ProcessConfig) -> Result { if let Some(runtime_base_dir) = config.worker_runtime_base_dir.clone() { factory = factory.with_runtime_base_dir(runtime_base_dir); } + if let Some(endpoint) = config.backend_resource_endpoint.clone() { + factory = factory.with_resource_client(Arc::new( + worker_runtime::resource::HttpBackendResourceClient::new( + endpoint, + config.backend_resource_token.clone(), + ), + )); + } if let Some(profile) = config.profile.clone() { factory = factory.with_profile(profile); } @@ -173,6 +181,13 @@ where config.worker_runtime_base_dir = Some(PathBuf::from(take_value(&flag, inline_value, &mut args)?)); } + "--backend-resource-endpoint" => { + config.backend_resource_endpoint = + Some(take_value(&flag, inline_value, &mut args)?); + } + "--backend-resource-token" => { + config.backend_resource_token = Some(take_value(&flag, inline_value, &mut args)?); + } "--profile" => { config.profile = Some(take_value(&flag, inline_value, &mut args)?); } @@ -317,6 +332,8 @@ struct ProcessConfig { worker_store_dir: Option, worker_metadata_dir: Option, worker_runtime_base_dir: Option, + backend_resource_endpoint: Option, + backend_resource_token: Option, profile: Option, } @@ -330,6 +347,8 @@ impl ProcessConfig { worker_store_dir: None, worker_metadata_dir: None, worker_runtime_base_dir: None, + backend_resource_endpoint: None, + backend_resource_token: None, profile: None, }) } @@ -405,6 +424,8 @@ Options:\n\ --worker-store-dir Worker session store directory\n\ --worker-metadata-dir Worker metadata directory\n\ --worker-runtime-base-dir Worker controller runtime directory\n\ + --backend-resource-endpoint Internal Backend resource fetch endpoint for resource handles\n\ + --backend-resource-token Optional bearer token for the Backend resource fetch endpoint\n\ --store Runtime catalog store selection (default: memory)\n\ --fs-root Runtime catalog filesystem store root\n\ --local-token Minimal local bearer token placeholder\n\ diff --git a/crates/worker-runtime/src/profile_archive.rs b/crates/worker-runtime/src/profile_archive.rs index cc7a6d09..b759c9d6 100644 --- a/crates/worker-runtime/src/profile_archive.rs +++ b/crates/worker-runtime/src/profile_archive.rs @@ -472,7 +472,7 @@ fn validate_archive_path(path: &str) -> Result<(), ProfileArchiveError> { Ok(()) } -fn sha256_hex(data: &[u8]) -> String { +pub fn sha256_hex(data: &[u8]) -> String { let digest = Sha256::digest(data); let mut out = String::from("sha256:"); for byte in digest.as_slice() { diff --git a/crates/worker-runtime/src/resource.rs b/crates/worker-runtime/src/resource.rs new file mode 100644 index 00000000..8335fc5c --- /dev/null +++ b/crates/worker-runtime/src/resource.rs @@ -0,0 +1,256 @@ +use crate::identity::{RuntimeId, WorkerId}; +use crate::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveRef, sha256_hex}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Mutex; + +pub const PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE: &str = + "application/vnd.yoi.profile-source-archive+tar"; +pub const DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES: u64 = 2 * 1024 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendResourceKind { + ProfileSourceArchive, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BackendResourceOperation { + FetchArchive, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackendResourceHandle { + pub kind: BackendResourceKind, + pub workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + pub resource_id: String, + pub digest: String, + pub operation: BackendResourceOperation, + pub expires_at_unix_seconds: i64, + pub nonce: String, + pub revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generation: Option, + pub max_bytes: u64, + pub content_type: String, + pub redaction: ResourceRedactionPolicy, + pub audit_correlation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_graph: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ResourceRedactionPolicy { + RuntimeInternalOnly, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackendResourceFetchRequest { + pub handle: BackendResourceHandle, + pub runtime_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + pub audit_correlation_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct BackendResourceFetchResponse { + pub kind: BackendResourceKind, + pub resource_id: String, + pub digest: String, + pub content_type: String, + pub bytes: Vec, + pub audit_correlation_id: String, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, thiserror::Error)] +#[serde(tag = "code", rename_all = "snake_case")] +pub enum BackendResourceError { + #[error("backend resource handle is expired")] + Expired, + #[error("backend resource handle is unauthorized: {message}")] + Unauthorized { message: String }, + #[error("backend resource kind is unsupported")] + UnsupportedKind, + #[error("backend resource is missing")] + MissingResource, + #[error("backend resource digest mismatch: expected {expected}, got {actual}")] + DigestMismatch { expected: String, actual: String }, + #[error("backend resource response is oversized: limit {max_bytes}, actual {actual_bytes}")] + Oversized { max_bytes: u64, actual_bytes: u64 }, + #[error("backend resource content type mismatch: expected {expected}, got {actual}")] + ContentTypeMismatch { expected: String, actual: String }, + #[error("backend resource transport failed: {message}")] + Transport { message: String }, + #[error("backend resource response is invalid: {message}")] + InvalidResponse { message: String }, +} + +#[async_trait] +pub trait BackendResourceClient: Send + Sync + 'static { + async fn fetch_resource( + &self, + request: BackendResourceFetchRequest, + ) -> Result; +} + +#[cfg(feature = "http-server")] +#[derive(Clone, Debug)] +pub struct HttpBackendResourceClient { + endpoint: String, + bearer_token: Option, + client: reqwest::Client, +} + +#[cfg(feature = "http-server")] +impl HttpBackendResourceClient { + pub fn new(endpoint: impl Into, bearer_token: Option) -> Self { + Self { + endpoint: endpoint.into(), + bearer_token, + client: reqwest::Client::new(), + } + } +} + +#[cfg(feature = "http-server")] +#[async_trait] +impl BackendResourceClient for HttpBackendResourceClient { + async fn fetch_resource( + &self, + request: BackendResourceFetchRequest, + ) -> Result { + let builder = self.client.post(&self.endpoint).json(&request); + let builder = if let Some(token) = self.bearer_token.as_deref() { + builder.bearer_auth(token) + } else { + builder + }; + let response = builder + .send() + .await + .map_err(|err| BackendResourceError::Transport { + message: err.to_string(), + })?; + if response.status().is_success() { + response + .json::() + .await + .map_err(|err| BackendResourceError::InvalidResponse { + message: err.to_string(), + }) + } else { + let status = response.status(); + match response.json::().await { + Ok(error) => Err(error), + Err(err) => Err(BackendResourceError::Transport { + message: format!("backend resource fetch failed with HTTP {status}: {err}"), + }), + } + } + } +} + +#[derive(Default, Debug)] +pub struct ProfileSourceArchiveCache { + archives: Mutex>, +} + +impl ProfileSourceArchiveCache { + pub fn get(&self, digest: &str) -> Option { + self.archives.lock().ok()?.get(digest).cloned() + } + + pub fn insert(&self, archive: ProfileSourceArchive) { + if let Ok(mut archives) = self.archives.lock() { + archives.insert(archive.reference.digest.clone(), archive); + } + } +} + +pub fn build_profile_source_archive_fetch_request( + handle: BackendResourceHandle, + runtime_id: &RuntimeId, + worker_id: Option<&WorkerId>, +) -> BackendResourceFetchRequest { + let audit_correlation_id = handle.audit_correlation_id.clone(); + BackendResourceFetchRequest { + handle, + runtime_id: runtime_id.as_str().to_string(), + worker_id: worker_id.map(|id| id.as_str().to_string()), + audit_correlation_id, + } +} + +pub fn profile_source_archive_from_response( + handle: &BackendResourceHandle, + response: BackendResourceFetchResponse, +) -> Result { + if handle.kind != BackendResourceKind::ProfileSourceArchive + || response.kind != BackendResourceKind::ProfileSourceArchive + { + return Err(BackendResourceError::UnsupportedKind); + } + if handle.operation != BackendResourceOperation::FetchArchive { + return Err(BackendResourceError::Unauthorized { + message: "resource handle operation is not fetch_archive".to_string(), + }); + } + if response.content_type != handle.content_type { + return Err(BackendResourceError::ContentTypeMismatch { + expected: handle.content_type.clone(), + actual: response.content_type, + }); + } + let actual_bytes = response.bytes.len() as u64; + if actual_bytes > handle.max_bytes { + return Err(BackendResourceError::Oversized { + max_bytes: handle.max_bytes, + actual_bytes, + }); + } + let actual_digest = sha256_hex(&response.bytes); + if actual_digest != handle.digest || response.digest != handle.digest { + return Err(BackendResourceError::DigestMismatch { + expected: handle.digest.clone(), + actual: if response.digest != handle.digest { + response.digest + } else { + actual_digest + }, + }); + } + Ok(ProfileSourceArchive { + reference: ProfileSourceArchiveRef { + id: handle.resource_id.clone(), + digest: handle.digest.clone(), + size_bytes: actual_bytes, + source_graph: handle.profile_source_graph.clone().ok_or_else(|| { + BackendResourceError::InvalidResponse { + message: "profile source archive handle omitted source graph summary" + .to_string(), + } + })?, + }, + content: response.bytes, + }) +} + +pub fn validate_resource_handle_text(label: &str, value: &str) -> Result<(), String> { + if value.trim().is_empty() { + return Err(format!("{label} must not be empty")); + } + if value.len() > 256 || value.contains('\0') || value.contains('\n') || value.contains('\r') { + return Err(format!("{label} contains unsupported boundary text")); + } + Ok(()) +} diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 8665d6f3..e9d7b87c 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -1628,6 +1628,7 @@ mod tests { reference: "capability:read".to_string(), }], profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index a5e62d4e..6863ae43 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -20,6 +20,10 @@ use crate::execution::{ WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult, }; use crate::interaction::{WorkerInput, WorkerInputKind}; +use crate::resource::{ + BackendResourceClient, BackendResourceError, ProfileSourceArchiveCache, + build_profile_source_archive_fetch_request, profile_source_archive_from_response, +}; use crate::working_directory::{WorkingDirectoryBinding, WorkingDirectoryMaterializer}; use async_trait::async_trait; use manifest::paths; @@ -46,7 +50,7 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static { /// Production factory that resolves a normal Worker profile and spawns it under /// `WorkerController`. -#[derive(Debug, Clone)] +#[derive(Clone)] pub struct ProfileRuntimeWorkerFactory { profile_base_dir: PathBuf, cwd: PathBuf, @@ -54,6 +58,8 @@ pub struct ProfileRuntimeWorkerFactory { worker_metadata_dir: Option, profile: Option, runtime_base_dir: Option, + resource_client: Option>, + profile_archive_cache: Arc, } impl ProfileRuntimeWorkerFactory { @@ -66,6 +72,8 @@ impl ProfileRuntimeWorkerFactory { worker_metadata_dir: None, profile: None, runtime_base_dir: None, + resource_client: None, + profile_archive_cache: Arc::new(ProfileSourceArchiveCache::default()), } } @@ -96,6 +104,11 @@ impl ProfileRuntimeWorkerFactory { self } + pub fn with_resource_client(mut self, resource_client: Arc) -> Self { + self.resource_client = Some(resource_client); + self + } + fn store_dir(&self) -> Result { self.store_dir .clone() @@ -152,6 +165,56 @@ impl ProfileRuntimeWorkerFactory { } Self::runtime_profile_value(&request.request.profile) } + async fn resolve_profile_source_archive( + &self, + bundle: &crate::config_bundle::ConfigBundle, + request: &WorkerExecutionSpawnRequest, + ) -> Result { + if let Some(archive) = verified_profile_source_archive(bundle) + .map_err(|err| format!("failed to verify profile source archive: {err}"))? + { + return Ok(archive); + } + let handle = bundle + .profile_source_archive_handle + .clone() + .ok_or_else(|| { + format!( + "config bundle {} does not contain a ProfileSourceArchive resource handle", + bundle.metadata.id + ) + })?; + if let Some(cached) = self.profile_archive_cache.get(&handle.digest) { + return cached + .verify() + .map_err(|err| format!("failed to verify cached profile source archive: {err}")); + } + let client = self.resource_client.as_ref().ok_or_else(|| { + format!( + "config bundle {} requires a Backend resource client for profile source archive fetch", + bundle.metadata.id + ) + })?; + let fetch_request = build_profile_source_archive_fetch_request( + handle.clone(), + &request.worker_ref.runtime_id, + Some(&request.worker_ref.worker_id), + ); + let response = client + .fetch_resource(fetch_request) + .await + .map_err(format_backend_resource_error)?; + let archive = profile_source_archive_from_response(&handle, response) + .map_err(format_backend_resource_error)?; + self.profile_archive_cache.insert(archive.clone()); + archive + .verify() + .map_err(|err| format!("failed to verify fetched profile source archive: {err}")) + } +} + +fn format_backend_resource_error(error: BackendResourceError) -> String { + format!("backend resource fetch failed: {error}") } #[async_trait] @@ -174,14 +237,9 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { .unwrap_or_else(|| self.cwd.clone()); let (mut manifest, loader) = if let Some(bundle) = request.config_bundle.as_ref() { let selector = profile.as_deref().unwrap_or("builtin:default"); - let archive = verified_profile_source_archive(bundle) - .map_err(|err| format!("failed to verify profile source archive: {err}"))? - .ok_or_else(|| { - format!( - "config bundle {} does not contain a ProfileSourceArchive", - bundle.metadata.id - ) - })?; + let archive = self + .resolve_profile_source_archive(bundle, &request) + .await?; let manifest = archive .resolve_profile(selector, &worker_root, &worker_name) .map_err(|err| format!("failed to resolve profile source archive: {err}"))?; @@ -791,6 +849,7 @@ mod tests { }], declarations: Vec::new(), profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index 27a00ed4..6f97f26e 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -455,6 +455,7 @@ fn companion_config_bundle() -> ConfigBundle { }], declarations: Vec::new(), profile_source_archive: Some(companion_profile_archive()), + profile_source_archive_handle: None, } .with_computed_digest() } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 290657d3..978a10b1 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -1,4 +1,5 @@ use crate::Error; +use crate::resource_broker::BackendResourceBroker; use chrono::Utc; use reqwest::StatusCode; use reqwest::blocking::{Client as BlockingHttpClient, RequestBuilder}; @@ -1032,6 +1033,7 @@ pub struct EmbeddedWorkerRuntime { host_id: String, runtime: worker_runtime::Runtime, execution_enabled: bool, + resource_broker: BackendResourceBroker, } fn embedded_runtime_options() -> EmbeddedRuntimeOptions { @@ -1082,6 +1084,11 @@ impl EmbeddedWorkerRuntime { Ok(embedded) } + pub fn with_resource_broker(mut self, resource_broker: BackendResourceBroker) -> Self { + self.resource_broker = resource_broker; + self + } + pub fn from_runtime(workspace_id: impl AsRef, runtime: worker_runtime::Runtime) -> Self { let runtime_id = runtime .runtime_id() @@ -1093,6 +1100,7 @@ impl EmbeddedWorkerRuntime { host_id: host_id_for_embedded_workspace(workspace_id.as_ref()), runtime, execution_enabled: false, + resource_broker: BackendResourceBroker::default(), } } @@ -1339,7 +1347,14 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .profile .clone() .unwrap_or_else(|| embedded_profile_selector(&request.intent)); - let config_bundle = match default_embedded_config_bundle(&profile).and_then(|bundle| { + let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone()); + let config_bundle = match default_embedded_config_bundle( + &profile, + &self.host_id, + runtime_id.as_ref(), + &self.resource_broker, + ) + .and_then(|bundle| { self.runtime .store_config_bundle(bundle) .map_err(|err| err.to_string()) @@ -1717,6 +1732,7 @@ pub struct RemoteWorkerRuntime { cached_capabilities: RuntimeCapabilitySummary, cached_status: String, host_id: String, + resource_broker: BackendResourceBroker, http: BlockingHttpClient, } @@ -1740,10 +1756,16 @@ impl RemoteWorkerRuntime { bearer_token: config.bearer_token, cached_capabilities: config.cached_capabilities, cached_status: config.cached_status, + resource_broker: BackendResourceBroker::default(), http, }) } + pub fn with_resource_broker(mut self, resource_broker: BackendResourceBroker) -> Self { + self.resource_broker = resource_broker; + self + } + fn endpoint(&self, path: &str) -> String { format!("{}{}", self.base_url, path) } @@ -2025,7 +2047,13 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { .profile .clone() .unwrap_or_else(|| embedded_profile_selector(&request.intent)); - let sync = match default_embedded_config_bundle(&profile) { + let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone()); + let sync = match default_embedded_config_bundle( + &profile, + &self.host_id, + runtime_id.as_ref(), + &self.resource_broker, + ) { Ok(bundle) => self.sync_config_bundle(bundle), Err(error) => ConfigBundleSyncResult { state: WorkerOperationState::Rejected, @@ -2365,19 +2393,31 @@ fn embedded_worker_execution_status_label( } } -fn default_embedded_config_bundle(profile: &ProfileSelector) -> Result { +fn default_embedded_config_bundle( + profile: &ProfileSelector, + workspace_id: &str, + runtime_id: Option<&EmbeddedRuntimeId>, + resource_broker: &BackendResourceBroker, +) -> Result { let id = format!( "workspace-runtime-{}", embedded_profile_label(profile) .unwrap_or_else(|| "default".to_string()) .replace([':', '/', ' '], "-") ); + let archive = default_profile_source_archive(profile)?; + let handle = resource_broker.issue_profile_source_archive_handle( + workspace_id.to_string(), + runtime_id, + None, + archive, + ); Ok(ConfigBundle { metadata: ConfigBundleMetadata { id, digest: String::new(), revision: "workspace-runtime-v0".to_string(), - workspace_id: "workspace-server".to_string(), + workspace_id: workspace_id.to_string(), created_at: "runtime-generated".to_string(), provenance: ConfigBundleProvenance { source: "workspace-server".to_string(), @@ -2389,7 +2429,8 @@ fn default_embedded_config_bundle(profile: &ProfileSelector) -> Result "default".to_string(), ProfileSelector::Builtin(name) => name.clone(), @@ -2996,14 +3056,25 @@ mod tests { #[test] fn embedded_archive_rejects_unknown_selectors() { + let broker = BackendResourceBroker::default(); + let runtime_id = EmbeddedRuntimeId::new("runtime-test".to_string()).unwrap(); assert!( - default_embedded_config_bundle(&ProfileSelector::Builtin( - "builtin:missing".to_string() - )) + default_embedded_config_bundle( + &ProfileSelector::Builtin("builtin:missing".to_string()), + "workspace-test", + Some(&runtime_id), + &broker, + ) .is_err() ); assert!( - default_embedded_config_bundle(&ProfileSelector::Named("custom".to_string())).is_err() + default_embedded_config_bundle( + &ProfileSelector::Named("custom".to_string()), + "workspace-test", + Some(&runtime_id), + &broker, + ) + .is_err() ); } @@ -3030,6 +3101,7 @@ mod tests { reference: "capability:read".to_string(), }], profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index d279a8d2..5507755d 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -11,6 +11,7 @@ pub mod identity; pub mod observation; pub mod records; pub mod repositories; +pub mod resource_broker; pub mod server; pub mod store; diff --git a/crates/workspace-server/src/resource_broker.rs b/crates/workspace-server/src/resource_broker.rs new file mode 100644 index 00000000..bfe4a4ca --- /dev/null +++ b/crates/workspace-server/src/resource_broker.rs @@ -0,0 +1,231 @@ +use async_trait::async_trait; +use chrono::{Duration, Utc}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use uuid::Uuid; +use worker_runtime::identity::{RuntimeId, WorkerId}; +use worker_runtime::profile_archive::ProfileSourceArchive; +use worker_runtime::resource::{ + BackendResourceClient, BackendResourceError, BackendResourceFetchRequest, + BackendResourceFetchResponse, BackendResourceHandle, BackendResourceKind, + BackendResourceOperation, DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, + PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE, ResourceRedactionPolicy, +}; + +#[derive(Clone, Default)] +pub struct BackendResourceBroker { + resources: Arc>>, +} + +#[derive(Clone)] +struct StoredResource { + workspace_id: String, + runtime_id: Option, + worker_id: Option, + handle: BackendResourceHandle, + archive: ProfileSourceArchive, +} + +impl BackendResourceBroker { + pub fn issue_profile_source_archive_handle( + &self, + workspace_id: impl Into, + runtime_id: Option<&RuntimeId>, + worker_id: Option<&WorkerId>, + archive: ProfileSourceArchive, + ) -> BackendResourceHandle { + let workspace_id = workspace_id.into(); + let nonce = Uuid::now_v7().to_string(); + let audit_correlation_id = format!("resource-fetch-{nonce}"); + let expires_at = Utc::now() + Duration::minutes(15); + let handle = BackendResourceHandle { + kind: BackendResourceKind::ProfileSourceArchive, + workspace_id: workspace_id.clone(), + scope_id: Some("workspace-profile-source".to_string()), + runtime_id: runtime_id.map(|id| id.as_str().to_string()), + worker_id: worker_id.map(|id| id.as_str().to_string()), + resource_id: archive.reference.id.clone(), + digest: archive.reference.digest.clone(), + operation: BackendResourceOperation::FetchArchive, + expires_at_unix_seconds: expires_at.timestamp(), + nonce: nonce.clone(), + revision: archive.reference.digest.clone(), + generation: None, + max_bytes: DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, + content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + redaction: ResourceRedactionPolicy::RuntimeInternalOnly, + audit_correlation_id, + profile_source_graph: Some(archive.reference.source_graph.clone()), + }; + let stored = StoredResource { + workspace_id, + runtime_id: runtime_id.map(|id| id.as_str().to_string()), + worker_id: worker_id.map(|id| id.as_str().to_string()), + handle: handle.clone(), + archive, + }; + if let Ok(mut resources) = self.resources.lock() { + resources.insert(nonce, stored); + } + handle + } + + pub fn fetch_profile_source_archive( + &self, + request: BackendResourceFetchRequest, + ) -> Result { + verify_handle_shape(&request.handle)?; + if request.handle.expires_at_unix_seconds < Utc::now().timestamp() { + return Err(BackendResourceError::Expired); + } + let stored = self + .resources + .lock() + .map_err(|_| BackendResourceError::Transport { + message: "resource broker lock poisoned".to_string(), + })? + .get(&request.handle.nonce) + .cloned() + .ok_or(BackendResourceError::MissingResource)?; + if stored.workspace_id != request.handle.workspace_id + || stored.runtime_id != request.handle.runtime_id + || stored.worker_id != request.handle.worker_id + || stored.handle.resource_id != request.handle.resource_id + || stored.handle.digest != request.handle.digest + || stored.handle.revision != request.handle.revision + { + return Err(BackendResourceError::Unauthorized { + message: "resource handle metadata does not match broker record".to_string(), + }); + } + if let Some(expected_runtime_id) = stored.runtime_id.as_deref() { + if expected_runtime_id != request.runtime_id { + return Err(BackendResourceError::Unauthorized { + message: "runtime id does not match resource handle".to_string(), + }); + } + } + if let Some(expected_worker_id) = stored.worker_id.as_deref() { + if Some(expected_worker_id) != request.worker_id.as_deref() { + return Err(BackendResourceError::Unauthorized { + message: "worker id does not match resource handle".to_string(), + }); + } + } + let actual_bytes = stored.archive.content.len() as u64; + if actual_bytes > request.handle.max_bytes { + return Err(BackendResourceError::Oversized { + max_bytes: request.handle.max_bytes, + actual_bytes, + }); + } + Ok(BackendResourceFetchResponse { + kind: BackendResourceKind::ProfileSourceArchive, + resource_id: stored.archive.reference.id, + digest: stored.archive.reference.digest, + content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + bytes: stored.archive.content, + audit_correlation_id: request.audit_correlation_id, + }) + } +} + +#[async_trait] +impl BackendResourceClient for BackendResourceBroker { + async fn fetch_resource( + &self, + request: BackendResourceFetchRequest, + ) -> Result { + self.fetch_profile_source_archive(request) + } +} + +fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendResourceError> { + if handle.kind != BackendResourceKind::ProfileSourceArchive { + return Err(BackendResourceError::UnsupportedKind); + } + if handle.operation != BackendResourceOperation::FetchArchive { + return Err(BackendResourceError::Unauthorized { + message: "resource handle operation is not fetch_archive".to_string(), + }); + } + if handle.content_type != PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE { + return Err(BackendResourceError::ContentTypeMismatch { + expected: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + actual: handle.content_type.clone(), + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use worker_runtime::identity::RuntimeId; + use worker_runtime::profile_archive::{ + ProfileSourceArchive, ProfileSourceArchiveRef, ProfileSourceGraphSummary, sha256_hex, + }; + + fn archive() -> ProfileSourceArchive { + let content = b"archive-content".to_vec(); + let mut entrypoints = BTreeMap::new(); + entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string()); + ProfileSourceArchive { + reference: ProfileSourceArchiveRef { + id: "profile-source-archive:test".to_string(), + digest: sha256_hex(&content), + size_bytes: content.len() as u64, + source_graph: ProfileSourceGraphSummary { + entrypoints, + source_count: 1, + import_count: 0, + total_source_bytes: content.len() as u64, + }, + }, + content, + } + } + + #[test] + fn broker_issues_and_verifies_profile_source_archive_handles() { + let broker = BackendResourceBroker::default(); + let runtime_id = RuntimeId::new("runtime-test").unwrap(); + let handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + None, + archive(), + ); + let response = broker + .fetch_profile_source_archive(BackendResourceFetchRequest { + handle: handle.clone(), + runtime_id: runtime_id.as_str().to_string(), + worker_id: None, + audit_correlation_id: handle.audit_correlation_id.clone(), + }) + .expect("fetch succeeds"); + assert_eq!(response.digest, handle.digest); + assert_eq!(response.content_type, PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE); + } + + #[test] + fn broker_rejects_runtime_mismatch() { + let broker = BackendResourceBroker::default(); + let handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&RuntimeId::new("runtime-a").unwrap()), + None, + archive(), + ); + let err = broker + .fetch_profile_source_archive(BackendResourceFetchRequest { + handle: handle.clone(), + runtime_id: RuntimeId::new("runtime-b").unwrap().as_str().to_string(), + worker_id: None, + audit_correlation_id: handle.audit_correlation_id.clone(), + }) + .unwrap_err(); + assert!(matches!(err, BackendResourceError::Unauthorized { .. })); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index b0f1ec80..6100f7c9 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -12,7 +12,8 @@ use chrono::{SecondsFormat, Utc}; use futures::StreamExt; use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; -use worker_runtime::worker_backend::WorkerRuntimeExecutionBackend; +use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; +use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use worker_runtime::working_directory::{ LocalGitWorktreeMaterializer, WorkingDirectoryDiagnostic, WorkingDirectoryMaterializer, }; @@ -42,6 +43,7 @@ use crate::repositories::{ ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, RepositoryRegistryReader, RepositorySummary, }; +use crate::resource_broker::BackendResourceBroker; use crate::store::{ControlPlaneStore, WorkspaceRecord}; use crate::{Error, Result}; use worker_runtime::catalog::{ @@ -154,6 +156,7 @@ pub struct WorkspaceApi { runtime: Arc, companion: Arc, observation_proxy: BackendObservationProxy, + resource_broker: BackendResourceBroker, working_directory_materializer: Arc, } @@ -162,21 +165,46 @@ impl WorkspaceApi { let materializer = Arc::new(LocalGitWorktreeMaterializer::new( config.embedded_runtime_store_root.clone(), )); - let execution_backend = - WorkerRuntimeExecutionBackend::from_workspace(config.workspace_root.clone()) - .map_err(|err| { - crate::Error::Store(format!( - "failed to initialize embedded Worker backend: {err}" - )) - })? - .with_working_directory_materializer((*materializer).clone()); - Self::new_with_execution_backend(config, store, Arc::new(execution_backend)).await + let resource_broker = BackendResourceBroker::default(); + let execution_backend = WorkerRuntimeExecutionBackend::new( + ProfileRuntimeWorkerFactory::new(config.workspace_root.clone()) + .with_resource_client(Arc::new(resource_broker.clone())), + ) + .map_err(|err| { + crate::Error::Store(format!( + "failed to initialize embedded Worker backend: {err}" + )) + })? + .with_working_directory_materializer((*materializer).clone()); + Self::new_with_execution_backend_and_broker( + config, + store, + Arc::new(execution_backend), + resource_broker, + ) + .await } + #[cfg(test)] async fn new_with_execution_backend( config: ServerConfig, store: Arc, execution_backend: Arc, + ) -> Result { + Self::new_with_execution_backend_and_broker( + config, + store, + execution_backend, + BackendResourceBroker::default(), + ) + .await + } + + async fn new_with_execution_backend_and_broker( + config: ServerConfig, + store: Arc, + execution_backend: Arc, + resource_broker: BackendResourceBroker, ) -> Result { store .upsert_workspace(&WorkspaceRecord { @@ -193,13 +221,17 @@ impl WorkspaceApi { config.embedded_runtime_store_root.clone(), execution_backend, ) + .map(|runtime| runtime.with_resource_broker(resource_broker.clone())) .map_err(|err| { crate::Error::Store(format!("invalid embedded Worker backend: {err}")) })?, ); for remote_config in config.remote_runtime_sources.iter().cloned() { - runtime - .register(RemoteWorkerRuntime::new(remote_config).map_err(|err| err.into_error())?); + runtime.register( + RemoteWorkerRuntime::new(remote_config) + .map(|host| host.with_resource_broker(resource_broker.clone())) + .map_err(|err| err.into_error())?, + ); } let runtime = Arc::new(runtime); let companion = Arc::new(CompanionConsole::new(runtime.clone())); @@ -214,6 +246,7 @@ impl WorkspaceApi { runtime, companion, observation_proxy, + resource_broker, working_directory_materializer, }) } @@ -328,6 +361,10 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/settings/runtime-connections/remotes/{runtime_id}/test", post(scoped_test_remote_runtime_connection), ) + .route( + "/internal/runtime/resources/fetch", + post(post_internal_runtime_resource_fetch), + ) .route("/api/companion/status", get(get_companion_status)) .route( "/api/w/{workspace_id}/companion/status", @@ -1445,7 +1482,9 @@ async fn add_remote_runtime_connection( vec![diagnostic], ) })?; - let active_runtime = RemoteWorkerRuntime::new(active_config).map_err(|err| err.into_error())?; + let active_runtime = RemoteWorkerRuntime::new(active_config) + .map(|host| host.with_resource_broker(api.resource_broker.clone())) + .map_err(|err| err.into_error())?; local_config.runtimes.remote.push(remote_config); write_workspace_backend_config_for_settings(&api, &local_config)?; api.runtime.register_or_replace(active_runtime); @@ -1696,6 +1735,33 @@ async fn create_workspace_worker( })) } +async fn post_internal_runtime_resource_fetch( + State(api): State, + Json(request): Json, +) -> std::result::Result< + Json, + (StatusCode, Json), +> { + api.resource_broker + .fetch_profile_source_archive(request) + .map(Json) + .map_err(|error| (backend_resource_error_status(&error), Json(error))) +} + +fn backend_resource_error_status(error: &BackendResourceError) -> StatusCode { + match error { + BackendResourceError::Expired => StatusCode::GONE, + BackendResourceError::Unauthorized { .. } => StatusCode::UNAUTHORIZED, + BackendResourceError::MissingResource => StatusCode::NOT_FOUND, + BackendResourceError::UnsupportedKind + | BackendResourceError::DigestMismatch { .. } + | BackendResourceError::Oversized { .. } + | BackendResourceError::ContentTypeMismatch { .. } + | BackendResourceError::InvalidResponse { .. } => StatusCode::BAD_REQUEST, + BackendResourceError::Transport { .. } => StatusCode::BAD_GATEWAY, + } +} + async fn get_companion_status( State(api): State, ) -> ApiResult> { @@ -3406,6 +3472,7 @@ mod tests { }], declarations: Vec::new(), profile_source_archive: None, + profile_source_archive_handle: None, } .with_computed_digest() } diff --git a/package.nix b/package.nix index 0b196b78..abe5476c 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-g9Bh9qQ1fGZtzsjO2/vc4jZdrH5YoCR0rOgs//xUDrU="; + cargoHash = "sha256-Pk6pRrtQlMAw2Shl+nQyX81FHQsHlKYAsxtg0M4Ya8Y="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, From 595da687518908477214fbd64e67d54a672400ae Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 20:20:32 +0900 Subject: [PATCH 03/18] ticket: record backend resource fetch implementation evidence --- .yoi/tickets/00001KX0G06VA/item.md | 2 +- .yoi/tickets/00001KX0G06VA/thread.md | 35 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX0G06VA/item.md b/.yoi/tickets/00001KX0G06VA/item.md index 73c61fbe..571271ac 100644 --- a/.yoi/tickets/00001KX0G06VA/item.md +++ b/.yoi/tickets/00001KX0G06VA/item.md @@ -2,7 +2,7 @@ title: 'Add Runtime-to-Backend resource fetch REST API' state: 'inprogress' created_at: '2026-07-08T09:12:33Z' -updated_at: '2026-07-08T10:40:25Z' +updated_at: '2026-07-08T11:20:23Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:04:10Z' diff --git a/.yoi/tickets/00001KX0G06VA/thread.md b/.yoi/tickets/00001KX0G06VA/thread.md index 6df722b7..6e70c750 100644 --- a/.yoi/tickets/00001KX0G06VA/thread.md +++ b/.yoi/tickets/00001KX0G06VA/thread.md @@ -176,3 +176,38 @@ Implementation routing update: - 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。 --- + + + +## Implementation report + +Implementation progress report: + +- Coder Pod completed implementation and committed `57e96d3b feat: add backend resource fetch api` on branch `work/00001KX0G06VA-resource-fetch-api`. +- Worktree status was clean after commit. +- Changed files include: + - `crates/worker-runtime/src/resource.rs` (new) + - `crates/workspace-server/src/resource_broker.rs` (new) + - `crates/worker-runtime/src/{config_bundle.rs,http_server.rs,main.rs,profile_archive.rs,worker_backend.rs}` + - `crates/workspace-server/src/{hosts.rs,server.rs,companion.rs}` + - `Cargo.lock`, `package.nix` +- Implementation summary: + - Added typed resource handle/request/response model and Runtime resource client/cache path。 + - Added Backend resource broker for v0 `profile_source_archive` resources。 + - Embedded direct path and remote Runtime HTTP path share the resource contract。 + - Runtime verifies digest, max bytes/content type, expiry/binding, and cache reuse before using fetched profile archives。 + - Config bundle/profile archive path can use resource handle without Runtime filesystem discovery。 + - Browser-facing summaries redact internal resource handles/endpoints/raw paths/archive details。 + - Added focused tests for handle issue/verify, runtime mismatch, digest mismatch, oversized response, direct/remote resource fetch, Browser redaction, and config-bundle/resource archive integration。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cargo test -p worker-runtime --features ws-server,fs-store`: pass(42 lib tests + 5 main tests + doc tests) + - `cargo test -p yoi-workspace-server`: pass(71 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass +- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。 + +--- From a0ef9a9f82ea55cff246e733d37fbade846f2e17 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 20:27:33 +0900 Subject: [PATCH 04/18] ticket: record backend resource fetch review blockers --- .yoi/tickets/00001KX0G06VA/item.md | 2 +- .yoi/tickets/00001KX0G06VA/thread.md | 43 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX0G06VA/item.md b/.yoi/tickets/00001KX0G06VA/item.md index 571271ac..5998b34f 100644 --- a/.yoi/tickets/00001KX0G06VA/item.md +++ b/.yoi/tickets/00001KX0G06VA/item.md @@ -2,7 +2,7 @@ title: 'Add Runtime-to-Backend resource fetch REST API' state: 'inprogress' created_at: '2026-07-08T09:12:33Z' -updated_at: '2026-07-08T11:20:23Z' +updated_at: '2026-07-08T11:27:23Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:04:10Z' diff --git a/.yoi/tickets/00001KX0G06VA/thread.md b/.yoi/tickets/00001KX0G06VA/thread.md index 6e70c750..4dfa1f61 100644 --- a/.yoi/tickets/00001KX0G06VA/thread.md +++ b/.yoi/tickets/00001KX0G06VA/thread.md @@ -211,3 +211,46 @@ Implementation progress report: - External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。 --- + + + +## Review: request changes + +External review result: request_changes + +Blockers: + +1. Backend handle verification が Runtime から送られた mutable handle fields を信頼している。 + - `crates/workspace-server/src/resource_broker.rs` の fetch validation は expiry / max-bytes を `request.handle` から確認しており、broker-stored issued handle の authority fields と照合していない。 + - 有効な nonce/resource/digest を持つ Runtime が以下を改変できる可能性がある。 + - `expires_at_unix_seconds` を延長して expiry を bypass。 + - `max_bytes` を増やして oversized rejection を bypass。 + - redaction/scope/generation など他 policy fields も stored issued handle に bind されていない。 + - Backend-issued handle を Backend が検証し、expired/oversized handles が fail closed typed diagnostics になるという Ticket requirement に反する。 + +2. Runtime cache が Backend authorization / expiry / binding を bypass している。 + - `ProfileRuntimeWorkerFactory::resolve_profile_source_archive` は fetch 前に digest keyed `profile_archive_cache` を見る。 + - 一度 archive が cache されると、同一 digest の後続 config bundle は Backend verification なしで reuse でき、expired handle / runtime-worker-workspace mismatch でも成功し得る。 + - 「Runtime cannot fetch/use Workspace-derived resources without Backend-issued handle」「Runtime verifies expiry/binding」「expired/unauthorized/mismatch cases produce diagnostics」に反する。 + +3. security contract の acceptance-level tests が不足している。 + - happy-path broker issue/fetch、runtime mismatch、browser projection redaction はあるが、以下の meaningful coverage が不足している。 + - tampered expired handle / expiry extension。 + - worker mismatch。 + - REST 経由 missing resource。 + - Runtime response verification の digest mismatch。 + - tampered `max_bytes` による oversized response。 + - remote HTTP resource-fetch path が同じ contract を使うこと。 + - 上記 defect が存在しているため、単なる coverage gap ではなく core Ticket intent の boundary regression を許している。 + +Validation / inspection performed: +- implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX0G06VA-resource-fetch-api` のみを使用。 +- Ticket item/thread/orchestration plan を確認。 +- `git show --stat --check 57e96d3b`, `git show --name-only 57e96d3b`, relevant implementation files を inspection。 +- Orchestrator-reported validation pass を参照し、Reviewer は heavy tests を再実行していない。 + +Non-blocking follow-ups: +- New `reqwest` dependency and `package.nix` cargo hash update は HTTP Runtime resource client と `nix build` pass により妥当。 +- broader resource kind 追加前に、stored issued handle policy fields の完全比較、または signed/opaque handle representation を検討するとよい。 + +--- From e716ae44f0c005a931fd49957b83c7a85ad93022 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 20:44:04 +0900 Subject: [PATCH 05/18] fix: harden backend resource handles --- crates/worker-runtime/src/resource.rs | 80 +++++++++ crates/worker-runtime/src/worker_backend.rs | 168 +++++++++++++++++- .../workspace-server/src/resource_broker.rs | 161 ++++++++++++++--- crates/workspace-server/src/server.rs | 154 +++++++++++++++- 4 files changed, 523 insertions(+), 40 deletions(-) diff --git a/crates/worker-runtime/src/resource.rs b/crates/worker-runtime/src/resource.rs index 8335fc5c..17c54978 100644 --- a/crates/worker-runtime/src/resource.rs +++ b/crates/worker-runtime/src/resource.rs @@ -254,3 +254,83 @@ pub fn validate_resource_handle_text(label: &str, value: &str) -> Result<(), Str } Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::profile_archive::ProfileSourceGraphSummary; + use std::collections::BTreeMap; + + fn graph() -> ProfileSourceGraphSummary { + ProfileSourceGraphSummary { + source_count: 1, + total_source_bytes: 4, + entrypoints: BTreeMap::from([( + "default".to_string(), + "profiles/default.dcdl".to_string(), + )]), + import_count: 0, + } + } + + fn handle_for(bytes: &[u8]) -> BackendResourceHandle { + BackendResourceHandle { + kind: BackendResourceKind::ProfileSourceArchive, + workspace_id: "workspace-test".to_string(), + scope_id: Some("workspace-profile-source".to_string()), + runtime_id: Some("runtime-test".to_string()), + worker_id: Some("worker-test".to_string()), + resource_id: "profile-source-archive:test".to_string(), + digest: sha256_hex(bytes), + operation: BackendResourceOperation::FetchArchive, + expires_at_unix_seconds: 4_102_444_800, + nonce: "nonce-test".to_string(), + revision: sha256_hex(bytes), + generation: Some(1), + max_bytes: 1024, + content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + redaction: ResourceRedactionPolicy::RuntimeInternalOnly, + audit_correlation_id: "audit-test".to_string(), + profile_source_graph: Some(graph()), + } + } + + #[test] + fn response_verification_detects_digest_mismatch() { + let bytes = b"archive-bytes"; + let handle = handle_for(bytes); + let error = profile_source_archive_from_response( + &handle, + BackendResourceFetchResponse { + kind: BackendResourceKind::ProfileSourceArchive, + resource_id: handle.resource_id.clone(), + digest: handle.digest.clone(), + content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + bytes: b"tampered-bytes".to_vec(), + audit_correlation_id: handle.audit_correlation_id.clone(), + }, + ) + .unwrap_err(); + assert!(matches!(error, BackendResourceError::DigestMismatch { .. })); + } + + #[test] + fn response_verification_rejects_oversized_bytes() { + let bytes = b"archive-bytes"; + let mut handle = handle_for(bytes); + handle.max_bytes = 2; + let error = profile_source_archive_from_response( + &handle, + BackendResourceFetchResponse { + kind: BackendResourceKind::ProfileSourceArchive, + resource_id: handle.resource_id.clone(), + digest: handle.digest.clone(), + content_type: PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + bytes: bytes.to_vec(), + audit_correlation_id: handle.audit_correlation_id.clone(), + }, + ) + .unwrap_err(); + assert!(matches!(error, BackendResourceError::Oversized { .. })); + } +} diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 6863ae43..b55fa011 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -184,11 +184,6 @@ impl ProfileRuntimeWorkerFactory { bundle.metadata.id ) })?; - if let Some(cached) = self.profile_archive_cache.get(&handle.digest) { - return cached - .verify() - .map_err(|err| format!("failed to verify cached profile source archive: {err}")); - } let client = self.resource_client.as_ref().ok_or_else(|| { format!( "config bundle {} requires a Backend resource client for profile source archive fetch", @@ -204,10 +199,15 @@ impl ProfileRuntimeWorkerFactory { .fetch_resource(fetch_request) .await .map_err(format_backend_resource_error)?; - let archive = profile_source_archive_from_response(&handle, response) + let fetched_archive = profile_source_archive_from_response(&handle, response) .map_err(format_backend_resource_error)?; - self.profile_archive_cache.insert(archive.clone()); - archive + if let Some(cached) = self.profile_archive_cache.get(&handle.digest) { + return cached + .verify() + .map_err(|err| format!("failed to verify cached profile source archive: {err}")); + } + self.profile_archive_cache.insert(fetched_archive.clone()); + fetched_archive .verify() .map_err(|err| format!("failed to verify fetched profile source archive: {err}")) } @@ -701,6 +701,7 @@ where #[cfg(test)] mod tests { use super::*; + use std::collections::{BTreeMap, VecDeque}; use std::fs; use std::pin::Pin; use std::process::Command; @@ -711,6 +712,7 @@ mod tests { ConfigBundleRef, CreateWorkerRequest, DirtyStatePolicy, MaterializerKind, ProfileSelector, RepositorySelector, WorkingDirectoryRepository, WorkingDirectoryRequest, }; + use crate::execution::WorkerExecutionContext; use crate::identity::RuntimeId; use crate::management::RuntimeOptions; use crate::observation::{TranscriptQuery, TranscriptRole}; @@ -854,6 +856,125 @@ mod tests { .with_computed_digest() } + fn sample_profile_archive() -> crate::profile_archive::ProfileSourceArchive { + let entrypoints = + BTreeMap::from([("default".to_string(), "profiles/default.dcdl".to_string())]); + let sources = BTreeMap::from([( + "profiles/default.dcdl".to_string(), + r#"{ + slug = "default"; + description = "Default"; + scope = "workspace_read"; + }"# + .to_string(), + )]); + crate::profile_archive::ProfileSourceArchive::build( + crate::profile_archive::ProfileSourceArchiveInput { + id: "profile-source-archive:test".to_string(), + entrypoints, + imports: BTreeMap::new(), + sources, + }, + ) + .unwrap() + } + + fn handle_for_archive( + archive: &crate::profile_archive::ProfileSourceArchive, + ) -> crate::resource::BackendResourceHandle { + crate::resource::BackendResourceHandle { + kind: crate::resource::BackendResourceKind::ProfileSourceArchive, + workspace_id: "workspace-test".to_string(), + scope_id: Some("workspace-profile-source".to_string()), + runtime_id: Some("runtime-test".to_string()), + worker_id: Some("worker-test".to_string()), + resource_id: archive.reference.id.clone(), + digest: archive.reference.digest.clone(), + operation: crate::resource::BackendResourceOperation::FetchArchive, + expires_at_unix_seconds: 4_102_444_800, + nonce: "nonce-test".to_string(), + revision: archive.reference.digest.clone(), + generation: Some(1), + max_bytes: crate::resource::DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, + content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + redaction: crate::resource::ResourceRedactionPolicy::RuntimeInternalOnly, + audit_correlation_id: "audit-test".to_string(), + profile_source_graph: Some(archive.reference.source_graph.clone()), + } + } + + fn response_for_archive( + handle: &crate::resource::BackendResourceHandle, + archive: &crate::profile_archive::ProfileSourceArchive, + ) -> crate::resource::BackendResourceFetchResponse { + crate::resource::BackendResourceFetchResponse { + kind: crate::resource::BackendResourceKind::ProfileSourceArchive, + resource_id: handle.resource_id.clone(), + digest: handle.digest.clone(), + content_type: crate::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + bytes: archive.content.clone(), + audit_correlation_id: handle.audit_correlation_id.clone(), + } + } + + #[derive(Clone)] + struct SequencedResourceClient { + responses: Arc< + Mutex< + VecDeque< + Result< + crate::resource::BackendResourceFetchResponse, + crate::resource::BackendResourceError, + >, + >, + >, + >, + call_count: Arc, + } + + #[async_trait] + impl crate::resource::BackendResourceClient for SequencedResourceClient { + async fn fetch_resource( + &self, + _request: crate::resource::BackendResourceFetchRequest, + ) -> Result< + crate::resource::BackendResourceFetchResponse, + crate::resource::BackendResourceError, + > { + self.call_count.fetch_add(1, Ordering::SeqCst); + self.responses + .lock() + .unwrap() + .pop_front() + .expect("missing sequenced resource response") + } + } + + fn spawn_request_with_bundle( + bundle: crate::config_bundle::ConfigBundle, + ) -> WorkerExecutionSpawnRequest { + let worker_ref = crate::identity::WorkerRef::new( + RuntimeId::new("runtime-test").unwrap(), + crate::identity::WorkerId::new("worker-test").unwrap(), + ); + WorkerExecutionSpawnRequest { + worker_ref: worker_ref.clone(), + request: CreateWorkerRequest { + profile: ProfileSelector::RuntimeDefault, + config_bundle: ConfigBundleRef { + id: bundle.metadata.id.clone(), + digest: bundle.metadata.digest.clone(), + }, + initial_input: None, + working_directory_request: None, + working_directory: None, + }, + context: WorkerExecutionContext::new(worker_ref, Arc::new(|_, _| panic!("unused"))), + working_directory: None, + config_bundle: Some(bundle), + } + } + fn create_request(_name: &str) -> CreateWorkerRequest { let bundle = test_bundle(); CreateWorkerRequest { @@ -906,6 +1027,37 @@ mod tests { } } + #[tokio::test] + async fn cached_profile_archive_still_requires_backend_authorization() { + let archive = sample_profile_archive(); + let handle = handle_for_archive(&archive); + let call_count = Arc::new(AtomicUsize::new(0)); + let client = SequencedResourceClient { + responses: Arc::new(Mutex::new(VecDeque::from([ + Ok(response_for_archive(&handle, &archive)), + Err(crate::resource::BackendResourceError::Expired), + ]))), + call_count: call_count.clone(), + }; + let factory = ProfileRuntimeWorkerFactory::new(tempfile::tempdir().unwrap().path()) + .with_resource_client(Arc::new(client)); + let mut bundle = test_bundle(); + bundle.profile_source_archive_handle = Some(handle); + bundle = bundle.with_computed_digest(); + + factory + .resolve_profile_source_archive(&bundle, &spawn_request_with_bundle(bundle.clone())) + .await + .expect("first fetch should authorize and cache archive"); + let err = factory + .resolve_profile_source_archive(&bundle, &spawn_request_with_bundle(bundle.clone())) + .await + .unwrap_err(); + + assert!(err.contains("expired"), "unexpected error: {err}"); + assert_eq!(call_count.load(Ordering::SeqCst), 2); + } + #[test] fn builtin_profile_selector_is_not_double_prefixed() { assert_eq!( diff --git a/crates/workspace-server/src/resource_broker.rs b/crates/workspace-server/src/resource_broker.rs index bfe4a4ca..a332d293 100644 --- a/crates/workspace-server/src/resource_broker.rs +++ b/crates/workspace-server/src/resource_broker.rs @@ -19,7 +19,6 @@ pub struct BackendResourceBroker { #[derive(Clone)] struct StoredResource { - workspace_id: String, runtime_id: Option, worker_id: Option, handle: BackendResourceHandle, @@ -58,7 +57,6 @@ impl BackendResourceBroker { profile_source_graph: Some(archive.reference.source_graph.clone()), }; let stored = StoredResource { - workspace_id, runtime_id: runtime_id.map(|id| id.as_str().to_string()), worker_id: worker_id.map(|id| id.as_str().to_string()), handle: handle.clone(), @@ -75,9 +73,6 @@ impl BackendResourceBroker { request: BackendResourceFetchRequest, ) -> Result { verify_handle_shape(&request.handle)?; - if request.handle.expires_at_unix_seconds < Utc::now().timestamp() { - return Err(BackendResourceError::Expired); - } let stored = self .resources .lock() @@ -87,15 +82,20 @@ impl BackendResourceBroker { .get(&request.handle.nonce) .cloned() .ok_or(BackendResourceError::MissingResource)?; - if stored.workspace_id != request.handle.workspace_id - || stored.runtime_id != request.handle.runtime_id - || stored.worker_id != request.handle.worker_id - || stored.handle.resource_id != request.handle.resource_id - || stored.handle.digest != request.handle.digest - || stored.handle.revision != request.handle.revision - { + verify_handle_shape(&stored.handle)?; + if stored.handle.expires_at_unix_seconds < Utc::now().timestamp() { + return Err(BackendResourceError::Expired); + } + let actual_bytes = stored.archive.content.len() as u64; + if actual_bytes > stored.handle.max_bytes { + return Err(BackendResourceError::Oversized { + max_bytes: stored.handle.max_bytes, + actual_bytes, + }); + } + if request.handle != stored.handle { return Err(BackendResourceError::Unauthorized { - message: "resource handle metadata does not match broker record".to_string(), + message: "resource handle does not match broker-issued handle".to_string(), }); } if let Some(expected_runtime_id) = stored.runtime_id.as_deref() { @@ -112,13 +112,6 @@ impl BackendResourceBroker { }); } } - let actual_bytes = stored.archive.content.len() as u64; - if actual_bytes > request.handle.max_bytes { - return Err(BackendResourceError::Oversized { - max_bytes: request.handle.max_bytes, - actual_bytes, - }); - } Ok(BackendResourceFetchResponse { kind: BackendResourceKind::ProfileSourceArchive, resource_id: stored.archive.reference.id, @@ -162,7 +155,7 @@ fn verify_handle_shape(handle: &BackendResourceHandle) -> Result<(), BackendReso mod tests { use super::*; use std::collections::BTreeMap; - use worker_runtime::identity::RuntimeId; + use worker_runtime::identity::{RuntimeId, WorkerId}; use worker_runtime::profile_archive::{ ProfileSourceArchive, ProfileSourceArchiveRef, ProfileSourceGraphSummary, sha256_hex, }; @@ -187,6 +180,39 @@ mod tests { } } + fn archive_with_len(len: usize) -> ProfileSourceArchive { + let content = vec![b'x'; len]; + let mut entrypoints = BTreeMap::new(); + entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string()); + ProfileSourceArchive { + reference: ProfileSourceArchiveRef { + id: format!("profile-source-archive:test-{len}"), + digest: sha256_hex(&content), + size_bytes: content.len() as u64, + source_graph: ProfileSourceGraphSummary { + entrypoints, + source_count: 1, + import_count: 0, + total_source_bytes: content.len() as u64, + }, + }, + content, + } + } + + fn request( + handle: BackendResourceHandle, + runtime_id: &RuntimeId, + worker_id: Option<&WorkerId>, + ) -> BackendResourceFetchRequest { + BackendResourceFetchRequest { + audit_correlation_id: handle.audit_correlation_id.clone(), + handle, + runtime_id: runtime_id.as_str().to_string(), + worker_id: worker_id.map(|id| id.as_str().to_string()), + } + } + #[test] fn broker_issues_and_verifies_profile_source_archive_handles() { let broker = BackendResourceBroker::default(); @@ -212,20 +238,99 @@ mod tests { #[test] fn broker_rejects_runtime_mismatch() { let broker = BackendResourceBroker::default(); + let runtime_a = RuntimeId::new("runtime-a").unwrap(); let handle = broker.issue_profile_source_archive_handle( "workspace-test", - Some(&RuntimeId::new("runtime-a").unwrap()), + Some(&runtime_a), None, archive(), ); let err = broker - .fetch_profile_source_archive(BackendResourceFetchRequest { - handle: handle.clone(), - runtime_id: RuntimeId::new("runtime-b").unwrap().as_str().to_string(), - worker_id: None, - audit_correlation_id: handle.audit_correlation_id.clone(), - }) + .fetch_profile_source_archive(request( + handle, + &RuntimeId::new("runtime-b").unwrap(), + None, + )) .unwrap_err(); assert!(matches!(err, BackendResourceError::Unauthorized { .. })); } + + #[test] + fn broker_rejects_worker_mismatch() { + let broker = BackendResourceBroker::default(); + let runtime_id = RuntimeId::new("runtime-test").unwrap(); + let worker_a = WorkerId::new("worker-a").unwrap(); + let worker_b = WorkerId::new("worker-b").unwrap(); + let handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + Some(&worker_a), + archive(), + ); + let err = broker + .fetch_profile_source_archive(request(handle, &runtime_id, Some(&worker_b))) + .unwrap_err(); + assert!(matches!(err, BackendResourceError::Unauthorized { .. })); + } + + #[test] + fn broker_rejects_expiry_extension_from_request_handle() { + let broker = BackendResourceBroker::default(); + let runtime_id = RuntimeId::new("runtime-test").unwrap(); + let handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + None, + archive(), + ); + broker + .resources + .lock() + .unwrap() + .get_mut(&handle.nonce) + .unwrap() + .handle + .expires_at_unix_seconds = 1; + let mut extended = handle; + extended.expires_at_unix_seconds = 4_102_444_800; + let err = broker + .fetch_profile_source_archive(request(extended, &runtime_id, None)) + .unwrap_err(); + assert!(matches!(err, BackendResourceError::Expired)); + } + + #[test] + fn broker_rejects_policy_tampered_request_handle() { + let broker = BackendResourceBroker::default(); + let runtime_id = RuntimeId::new("runtime-test").unwrap(); + let mut handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + None, + archive(), + ); + handle.scope_id = Some("tampered-scope".to_string()); + let err = broker + .fetch_profile_source_archive(request(handle, &runtime_id, None)) + .unwrap_err(); + assert!(matches!(err, BackendResourceError::Unauthorized { .. })); + } + + #[test] + fn broker_uses_stored_max_bytes_when_request_handle_is_tampered() { + let broker = BackendResourceBroker::default(); + let runtime_id = RuntimeId::new("runtime-test").unwrap(); + let archive = archive_with_len((DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1) as usize); + let mut handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + None, + archive, + ); + handle.max_bytes = DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES + 1024; + let err = broker + .fetch_profile_source_archive(request(handle, &runtime_id, None)) + .unwrap_err(); + assert!(matches!(err, BackendResourceError::Oversized { .. })); + } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 6100f7c9..a34f1c80 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3269,6 +3269,7 @@ mod tests { use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use tower::ServiceExt; + use worker_runtime::resource::BackendResourceClient; use crate::hosts::{ TicketWorkerRole, WorkerInputKind, WorkerOperationState, WorkerSpawnAcceptanceRequirement, @@ -3441,16 +3442,161 @@ mod tests { } } - async fn test_app(workspace_root: impl Into) -> Router { + async fn test_api(workspace_root: impl Into) -> WorkspaceApi { let store = SqliteWorkspaceStore::in_memory().unwrap(); - let api = WorkspaceApi::new_with_execution_backend( + WorkspaceApi::new_with_execution_backend( test_server_config(workspace_root), Arc::new(store), Arc::new(DeterministicExecutionBackend::default()), ) .await - .unwrap(); - build_router(api) + .unwrap() + } + + async fn test_app(workspace_root: impl Into) -> Router { + build_router(test_api(workspace_root).await) + } + + fn test_profile_archive() -> worker_runtime::profile_archive::ProfileSourceArchive { + use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; + ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: "profile-source-archive:server-test".to_string(), + entrypoints: std::collections::BTreeMap::from([( + "default".to_string(), + "profiles/default.dcdl".to_string(), + )]), + imports: std::collections::BTreeMap::new(), + sources: std::collections::BTreeMap::from([( + "profiles/default.dcdl".to_string(), + r#"{ + slug = "default"; + description = "Default"; + scope = "workspace_read"; + }"# + .to_string(), + )]), + }) + .unwrap() + } + + fn missing_resource_handle() -> worker_runtime::resource::BackendResourceHandle { + worker_runtime::resource::BackendResourceHandle { + kind: worker_runtime::resource::BackendResourceKind::ProfileSourceArchive, + workspace_id: "workspace-test".to_string(), + scope_id: Some("workspace-profile-source".to_string()), + runtime_id: Some("runtime-test".to_string()), + worker_id: None, + resource_id: "profile-source-archive:missing".to_string(), + digest: "sha256:0000000000000000000000000000000000000000000000000000000000000000" + .to_string(), + operation: worker_runtime::resource::BackendResourceOperation::FetchArchive, + expires_at_unix_seconds: 4_102_444_800, + nonce: "missing-nonce".to_string(), + revision: "missing-revision".to_string(), + generation: None, + max_bytes: worker_runtime::resource::DEFAULT_PROFILE_SOURCE_ARCHIVE_MAX_BYTES, + content_type: worker_runtime::resource::PROFILE_SOURCE_ARCHIVE_CONTENT_TYPE.to_string(), + redaction: worker_runtime::resource::ResourceRedactionPolicy::RuntimeInternalOnly, + audit_correlation_id: "audit-missing".to_string(), + profile_source_graph: Some( + worker_runtime::profile_archive::ProfileSourceGraphSummary { + entrypoints: std::collections::BTreeMap::from([( + "default".to_string(), + "profiles/default.dcdl".to_string(), + )]), + source_count: 1, + import_count: 0, + total_source_bytes: 0, + }, + ), + } + } + + #[tokio::test] + async fn internal_resource_fetch_rest_returns_typed_missing_resource() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let app = test_app(workspace.path()).await; + let handle = missing_resource_handle(); + let response = app + .oneshot( + Request::post("/internal/runtime/resources/fetch") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec( + &worker_runtime::resource::BackendResourceFetchRequest { + audit_correlation_id: handle.audit_correlation_id.clone(), + runtime_id: "runtime-test".to_string(), + worker_id: None, + handle, + }, + ) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let error: worker_runtime::resource::BackendResourceError = + serde_json::from_slice(&bytes).unwrap(); + assert!(matches!( + error, + worker_runtime::resource::BackendResourceError::MissingResource + )); + } + + #[tokio::test] + async fn remote_http_resource_fetch_uses_backend_resource_contract() { + let workspace = tempfile::tempdir().unwrap(); + init_clean_git_workspace(workspace.path()); + let api = test_api(workspace.path()).await; + let broker = api.resource_broker.clone(); + let archive = test_profile_archive(); + let runtime_id = worker_runtime::identity::RuntimeId::new("runtime-test").unwrap(); + let handle = broker.issue_profile_source_archive_handle( + "workspace-test", + Some(&runtime_id), + None, + archive, + ); + let app = build_router(api); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); + let client = worker_runtime::resource::HttpBackendResourceClient::new( + format!("http://{addr}/internal/runtime/resources/fetch"), + None, + ); + + let response = client + .fetch_resource(worker_runtime::resource::BackendResourceFetchRequest { + audit_correlation_id: handle.audit_correlation_id.clone(), + runtime_id: runtime_id.as_str().to_string(), + worker_id: None, + handle: handle.clone(), + }) + .await + .expect("remote HTTP resource fetch succeeds"); + assert_eq!(response.digest, handle.digest); + + let mut tampered = handle; + tampered.scope_id = Some("tampered".to_string()); + let error = client + .fetch_resource(worker_runtime::resource::BackendResourceFetchRequest { + audit_correlation_id: tampered.audit_correlation_id.clone(), + runtime_id: runtime_id.as_str().to_string(), + worker_id: None, + handle: tampered, + }) + .await + .unwrap_err(); + assert!(matches!( + error, + worker_runtime::resource::BackendResourceError::Unauthorized { .. } + )); + server.abort(); } fn runtime_test_bundle() -> worker_runtime::config_bundle::ConfigBundle { From 237c75448c8ebea573d83c0f1f2da19982ba5ec4 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 20:45:52 +0900 Subject: [PATCH 06/18] ticket: record backend resource fetch review fixes --- .yoi/tickets/00001KX0G06VA/item.md | 2 +- .yoi/tickets/00001KX0G06VA/thread.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX0G06VA/item.md b/.yoi/tickets/00001KX0G06VA/item.md index 5998b34f..75998fdf 100644 --- a/.yoi/tickets/00001KX0G06VA/item.md +++ b/.yoi/tickets/00001KX0G06VA/item.md @@ -2,7 +2,7 @@ title: 'Add Runtime-to-Backend resource fetch REST API' state: 'inprogress' created_at: '2026-07-08T09:12:33Z' -updated_at: '2026-07-08T11:27:23Z' +updated_at: '2026-07-08T11:45:42Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:04:10Z' diff --git a/.yoi/tickets/00001KX0G06VA/thread.md b/.yoi/tickets/00001KX0G06VA/thread.md index 4dfa1f61..d17bf54e 100644 --- a/.yoi/tickets/00001KX0G06VA/thread.md +++ b/.yoi/tickets/00001KX0G06VA/thread.md @@ -254,3 +254,31 @@ Non-blocking follow-ups: - broader resource kind 追加前に、stored issued handle policy fields の完全比較、または signed/opaque handle representation を検討するとよい。 --- + + + +## Implementation report + +Review-fix implementation report: + +- Coder Pod completed requested changes and committed follow-up `e716ae44 fix: harden backend resource handles` on branch `work/00001KX0G06VA-resource-fetch-api`. +- Worktree status was clean after commit. +- Fix summary: + - Backend broker now treats broker-stored issued handle as authority。 + - Request handle must match stored issued handle; expiry and `max_bytes` validation use stored policy fields, not mutable Runtime-provided fields。 + - Tampered scope/policy fields fail closed。 + - Runtime archive cache no longer bypasses Backend authorization: Runtime calls `BackendResourceClient` and verifies response before reusing digest-cached archive。 + - Expired/unauthorized handles now block cached reuse。 + - Added focused security-contract tests for expiry extension/expired handle rejection, worker mismatch, policy tampering, REST missing-resource diagnostic, Runtime digest mismatch, oversized response despite `max_bytes` tampering, and remote HTTP path using the same Backend contract as direct broker path。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cargo test -p worker-runtime --features ws-server,fs-store`: pass(45 lib tests + 5 main tests + doc tests) + - `cargo test -p yoi-workspace-server`: pass(77 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass +- Requesting follow-up external review against `57e96d3b..e716ae44` and full combined implementation。 + +--- From fd1ab4bad896718bbe5081608c09839f6dc3fdf5 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 20:53:12 +0900 Subject: [PATCH 07/18] ticket: approve backend resource fetch work --- .yoi/tickets/00001KX0G06VA/item.md | 2 +- .yoi/tickets/00001KX0G06VA/thread.md | 35 ++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX0G06VA/item.md b/.yoi/tickets/00001KX0G06VA/item.md index 75998fdf..2211e0f6 100644 --- a/.yoi/tickets/00001KX0G06VA/item.md +++ b/.yoi/tickets/00001KX0G06VA/item.md @@ -2,7 +2,7 @@ title: 'Add Runtime-to-Backend resource fetch REST API' state: 'inprogress' created_at: '2026-07-08T09:12:33Z' -updated_at: '2026-07-08T11:45:42Z' +updated_at: '2026-07-08T11:53:02Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:04:10Z' diff --git a/.yoi/tickets/00001KX0G06VA/thread.md b/.yoi/tickets/00001KX0G06VA/thread.md index d17bf54e..3e56961d 100644 --- a/.yoi/tickets/00001KX0G06VA/thread.md +++ b/.yoi/tickets/00001KX0G06VA/thread.md @@ -282,3 +282,38 @@ Review-fix implementation report: - Requesting follow-up external review against `57e96d3b..e716ae44` and full combined implementation。 --- + + + +## Review: approve + +External follow-up review result: approve + +Blockers: none. + +Validation performed: +- Read-only review in implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX0G06VA-resource-fetch-api`。 +- Ticket item/thread, branch/log/status, `57e96d3b..e716ae44` fix diff, and relevant implementation files を確認。 +- Reviewer は validation suite を再実行せず、Orchestrator-reported passing validation を evidence として確認。 + +Findings: +- Prior blocker 1 fixed: Backend は mutable request handle policy fields を信頼しない。`BackendResourceBroker` は issued `BackendResourceHandle` を resource と共に保存し、stored expiry/max_bytes を検証してから request handle と stored issued handle を比較する。tampering は `Expired` / `Oversized` / `Unauthorized` diagnostics で fail closed。expiry extension、policy tampering、worker/runtime mismatch、max_bytes tampering tests あり。 +- Prior blocker 2 fixed: Runtime cache は Backend authorization を bypass しない。`resolve_profile_source_archive` は先に Backend fetch を行い、handle に対して response verify してから digest cache を reuse/insert する。cache test は second-use backend `Expired` rejection と backend call count を確認している。 +- Prior blocker 3 fixed: security contract tests が meaningful に追加されている。 + - `resource_broker.rs`: expired/expiry extension、worker mismatch、runtime mismatch、policy tampering、stored max_bytes oversized。 + - `resource.rs`: Runtime digest mismatch と oversized response verification。 + - `server.rs`: REST missing-resource diagnostic と remote HTTP contract/tampering path。 + - `worker_backend.rs`: cache authorization。 + - Browser redaction: `ConfigBundleSummary` redaction test。 +- Typed/direct/remote contract: `BackendResourceFetchRequest/Response/Error` は shared typed structs。embedded direct path は `BackendResourceBroker` を `BackendResourceClient` として使い、remote HTTP は同じ request shape を `/internal/runtime/resources/fetch` に POST する。 +- Browser-facing redaction remains strong: browser-safe summaries は profile source graph summary のみを出し、handle nonce/resource id/archive digest/runtime-worker binding/token/internal endpoint/raw path を出さない。 +- ProfileSourceArchive integration は normal Backend/Browser launch で Backend-owned resource boundary を使う。generated bundles は resource handle を持ち inline archive を持たず、Runtime は fetched archive bytes から resolve する。filesystem profile discovery は explicit no-config-bundle compatibility/debug fallback のみ。 + +Non-blocking follow-ups: +- stored `audit_correlation_id` の echo と Runtime-side response `resource_id` check を追加すると diagnostic integrity がさらに強くなる。 +- remote HTTP resource fetch が trusted Backend/Runtime links を越えて露出する場合は、JSON body materialization 前の explicit client-side timeout/response-size limiting を検討する。 + +Effect on queued `00001KX0DSMPT`: +- blocking concern なし。profile/archive details は redacted summaries behind boundary に留まっており、future settings UI/API が profile metadata を consume しても handles を露出しない設計と互換。 + +--- From a645ee5b790ae428f10e9871b1cb3474cea0450f Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 20:55:03 +0900 Subject: [PATCH 08/18] ticket: close backend resource fetch work --- .yoi/tickets/00001KX0G06VA/item.md | 4 +- .yoi/tickets/00001KX0G06VA/resolution.md | 36 +++++++++++ .yoi/tickets/00001KX0G06VA/thread.md | 78 ++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KX0G06VA/resolution.md diff --git a/.yoi/tickets/00001KX0G06VA/item.md b/.yoi/tickets/00001KX0G06VA/item.md index 2211e0f6..479a0574 100644 --- a/.yoi/tickets/00001KX0G06VA/item.md +++ b/.yoi/tickets/00001KX0G06VA/item.md @@ -1,8 +1,8 @@ --- title: 'Add Runtime-to-Backend resource fetch REST API' -state: 'inprogress' +state: 'closed' created_at: '2026-07-08T09:12:33Z' -updated_at: '2026-07-08T11:53:02Z' +updated_at: '2026-07-08T11:54:55Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:04:10Z' diff --git a/.yoi/tickets/00001KX0G06VA/resolution.md b/.yoi/tickets/00001KX0G06VA/resolution.md new file mode 100644 index 00000000..50339b2a --- /dev/null +++ b/.yoi/tickets/00001KX0G06VA/resolution.md @@ -0,0 +1,36 @@ +完了。 + +実装内容: +- Runtime-to-Backend resource fetch contract を追加した。 +- Backend-issued typed resource handle と `BackendResourceBroker` を実装した。 +- v0 resource kind `profile_source_archive` を fetch / verify / cache できるようにした。 +- embedded direct Runtime path と remote Runtime HTTP path が同じ typed resource contract を使うようにした。 +- Runtime は handle に基づき archive を fetch し、digest / max bytes / content type / expiry / binding を検証してから cache/reuse するようにした。 +- Backend broker は stored issued handle を authority として扱い、Runtime-provided mutable handle fields による expiry / max_bytes / policy tampering を fail closed するようにした。 +- Runtime cache は Backend authorization / expiry / binding を bypass しないようにした。 +- Decodal ProfileSourceArchive / config bundle path を resource fetch boundary に接続し、Runtime filesystem discovery を再導入しない形にした。 +- Browser-facing summaries は internal resource handle / endpoint / raw path / archive content/digest/store path / token 等を出さないようにした。 +- Focused worker-runtime / workspace-server tests を追加した。 + +主な commit / merge: +- implementation: `57e96d3b feat: add backend resource fetch api` +- review fix: `e716ae44 fix: harden backend resource handles` +- merge into orchestration: `01f94b75 merge: backend resource fetch api` + +Review: +- 初回 external review は request_changes。 +- follow-up external review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(45 lib tests + 5 main tests + doc tests) +- `cargo test -p yoi-workspace-server`: pass(77 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(17 tests) +- `yoi ticket doctor`: ok +- `nix build .#yoi --no-link`: pass + +補足: +- Reviewer の non-blocking follow-up: stored `audit_correlation_id` echo と Runtime-side response `resource_id` check、remote HTTP response-size limiting のさらなる強化余地。 +- queued `00001KX0DSMPT` については、resource-fetch API shape が確定したため再 routing 可能。 \ No newline at end of file diff --git a/.yoi/tickets/00001KX0G06VA/thread.md b/.yoi/tickets/00001KX0G06VA/thread.md index 3e56961d..689736c6 100644 --- a/.yoi/tickets/00001KX0G06VA/thread.md +++ b/.yoi/tickets/00001KX0G06VA/thread.md @@ -317,3 +317,81 @@ Effect on queued `00001KX0DSMPT`: - blocking concern なし。profile/archive details は redacted summaries behind boundary に留まっており、future settings UI/API が profile metadata を consume しても handles を露出しない設計と互換。 --- + + + +## State changed + +Merge/validation acceptance: + +- Follow-up external review approved the implementation after blocker fix。 +- Implementation branch `work/00001KX0G06VA-resource-fetch-api` was merged into the orchestration branch with merge commit `01f94b75 merge: backend resource fetch api`。 +- Final validation in Orchestrator worktree passed: + - `git diff --check`: pass + - `cargo test -p worker-runtime --features ws-server,fs-store`: pass(45 lib tests + 5 main tests + doc tests) + - `cargo test -p yoi-workspace-server`: pass(77 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass + +Implementation commits: +- `57e96d3b feat: add backend resource fetch api` +- `e716ae44 fix: harden backend resource handles` + +No merge-blocking issues remain. Reviewer noted no blocking concern for queued `00001KX0DSMPT`; the resource-fetch API shape is now stable enough for that follow-up to be re-routed. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +完了。 + +実装内容: +- Runtime-to-Backend resource fetch contract を追加した。 +- Backend-issued typed resource handle と `BackendResourceBroker` を実装した。 +- v0 resource kind `profile_source_archive` を fetch / verify / cache できるようにした。 +- embedded direct Runtime path と remote Runtime HTTP path が同じ typed resource contract を使うようにした。 +- Runtime は handle に基づき archive を fetch し、digest / max bytes / content type / expiry / binding を検証してから cache/reuse するようにした。 +- Backend broker は stored issued handle を authority として扱い、Runtime-provided mutable handle fields による expiry / max_bytes / policy tampering を fail closed するようにした。 +- Runtime cache は Backend authorization / expiry / binding を bypass しないようにした。 +- Decodal ProfileSourceArchive / config bundle path を resource fetch boundary に接続し、Runtime filesystem discovery を再導入しない形にした。 +- Browser-facing summaries は internal resource handle / endpoint / raw path / archive content/digest/store path / token 等を出さないようにした。 +- Focused worker-runtime / workspace-server tests を追加した。 + +主な commit / merge: +- implementation: `57e96d3b feat: add backend resource fetch api` +- review fix: `e716ae44 fix: harden backend resource handles` +- merge into orchestration: `01f94b75 merge: backend resource fetch api` + +Review: +- 初回 external review は request_changes。 +- follow-up external review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(45 lib tests + 5 main tests + doc tests) +- `cargo test -p yoi-workspace-server`: pass(77 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(17 tests) +- `yoi ticket doctor`: ok +- `nix build .#yoi --no-link`: pass + +補足: +- Reviewer の non-blocking follow-up: stored `audit_correlation_id` echo と Runtime-side response `resource_id` check、remote HTTP response-size limiting のさらなる強化余地。 +- queued `00001KX0DSMPT` については、resource-fetch API shape が確定したため再 routing 可能。 + +--- From c903beee6909739b30f3e7a650df90d441dbe941 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 20:56:51 +0900 Subject: [PATCH 09/18] ticket: accept workspace profile settings work --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KX0DSMPT/item.md | 4 +- .yoi/tickets/00001KX0DSMPT/thread.md | 93 +++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) diff --git a/.yoi/tickets/00001KX0DSMPT/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KX0DSMPT/artifacts/orchestration-plan.jsonl index 7d83e691..03de8e07 100644 --- a/.yoi/tickets/00001KX0DSMPT/artifacts/orchestration-plan.jsonl +++ b/.yoi/tickets/00001KX0DSMPT/artifacts/orchestration-plan.jsonl @@ -1,2 +1,3 @@ {"id":"orch-plan-20260708-104512-1","ticket_id":"00001KX0DSMPT","kind":"after","related_ticket":"00001KX0G06VA","note":"This Ticket is queued while `00001KX0G06VA` is inprogress because both touch Workspace/Backend profile/resource launch surfaces (`workspace-server` settings/API/hosts/server and profile archive/resource fetch integration). Sequence after the active resource-fetch implementation to avoid conflicting public API and ProfileSourceArchive authority-boundary changes.","author":"orchestrator","at":"2026-07-08T10:45:12Z"} {"id":"orch-plan-20260708-104530-2","ticket_id":"00001KX0DSMPT","kind":"waiting_capacity_note","note":"Queue review after Dashboard authorization. The declared dependency `00001KWZ5KERY` is closed, so that blocker is resolved. However `00001KX0G06VA` is currently inprogress on branch `work/00001KX0G06VA-resource-fetch-api` and changes the same ProfileSourceArchive/resource/workspace-server launch API surfaces this Ticket needs to build on. Leave this Ticket queued until the active resource-fetch branch is reviewed/merged or its API shape is otherwise decided; then re-route/start from the updated orchestration branch.","author":"orchestrator","at":"2026-07-08T10:45:30Z"} +{"id":"orch-plan-20260708-115622-3","ticket_id":"00001KX0DSMPT","kind":"accepted_plan","accepted_plan":{"summary":"`00001KWZ5KERY` と `00001KX0G06VA` が closed/merged になり、previous waiting reason は解除された。Workspace/Profile editing API/pages を updated orchestration branch から実装開始する。","branch":"work/00001KX0DSMPT-workspace-profile-settings","worktree":"/home/hare/Projects/yoi/.worktree/00001KX0DSMPT-workspace-profile-settings","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、Workspace/Profile settings scoped APIs、safe Profile registry/source read-write model、settings UI pages、Backend discovery invalidation、launch candidate integration、diagnostic/redaction tests を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / ProfileSourceArchive/resource-fetch authority boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-08T11:56:22Z"} diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index a9bbc33d..4a55a181 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -1,8 +1,8 @@ --- title: 'Add Workspace settings API and pages for Workspace/Profile editing' -state: 'queued' +state: 'inprogress' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T10:45:48Z' +updated_at: '2026-07-08T11:56:36Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index 03d3bb12..19fdecab 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -60,3 +60,96 @@ Escalate if: - 人間が `00001KX0G06VA` を中断してこの Ticket を先に実装する、または両者を同一 combined worktree で統合実装する方針に切り替えたい場合。 --- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Previous waiting reason is resolved: `00001KX0G06VA` is now merged/validated/closed, so the ProfileSourceArchive resource-fetch API shape this Ticket depends on is stable enough to build against。 +- Declared dependency `00001KWZ5KERY` is also closed, so Decodal Profile source model / ProfileSourceArchive boundary is available。 +- Ticket body gives concrete Workspace/Profile settings API, read/write source model, UI routes, diagnostics, redaction invariants, and validation criteria。 +- typed relation blocker is resolved (`depends_on 00001KWZ5KERY` target closed); no active inprogress Ticket remains。 +- The user requested sequential consumption once no blocker remains, and the Dashboard queue authorization is already present。 + +Evidence checked: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KX0DSMPT)`: outgoing `depends_on 00001KWZ5KERY`; target is closed。 +- `TicketOrchestrationPlanQuery(00001KX0DSMPT)`: prior `after 00001KX0G06VA` / waiting note; `00001KX0G06VA` is now closed with merge commit `01f94b75` and close commit `a645ee5b`。 +- Orchestrator worktree git status: clean on `orchestration`。 +- queued Ticket 一覧: this Ticket 1件。inprogress は 0 件。 + +IntentPacket: + +Intent: +- Workspace-scoped Browser API and pages for Workspace metadata and Profile registry/source editing, using Backend as the authority and the Decodal/ProfileSourceArchive model already merged。 +- Frontend should edit Profiles through safe Backend APIs, not filesystem paths, and Worker launch profile candidates should reflect updated Backend discovery/validation state。 + +Binding decisions / invariants: +- Workspace/Profile editing APIs live under `/api/w//settings...` or consistent scoped Workspace API paths。 +- Browser-facing API/UI must not expose host absolute paths, secret values, Runtime endpoints/tokens, socket/session paths, runtime-local store paths, archive content/digest, or raw resource handles。 +- Profile source references use safe ids/artifact ids/display paths rather than raw absolute paths。 +- Writes must validate syntax/schema/selector uniqueness/path safety before commit and return typed diagnostics。 +- Decodal/ProfileSourceArchive source model from `00001KWZ5KERY` and resource-fetch boundary from `00001KX0G06VA` are authority inputs; do not reintroduce Runtime filesystem discovery。 +- Runtime direct sync is out of scope; Backend discovery/launch options invalidation is in scope。 + +Requirements / acceptance criteria: +- Workspace metadata read/update API works for display name and safe identity/source summaries。 +- Profile registry/source read/update APIs support create/update/delete with revision/etag-style optimistic check。 +- Settings UI has Workspace overview and Profile list/source editor routes under `/w//settings...`。 +- Profile updates re-run Backend discovery/validation and update Worker launch profile candidates。 +- Duplicate selector, invalid registry schema, invalid Decodal syntax, path/symlink escape, artifact too large, and concurrent update conflict return typed diagnostics。 +- Browser WorkingDirectory / Worker launch profile candidates and settings Profile list share the same Backend discovery result。 + +Implementation latitude: +- Endpoint naming, module boundaries, revision token shape, source/artifact id representation, UI component split, and editor implementation can follow existing workspace-server / Svelte style。 +- v0 plain textarea/editor is acceptable; no heavyweight editor required。 +- Imported source write support can be limited to bounded module root or deferred with typed diagnostics if not needed for core v0。 + +Escalate if: +- Secret value editor, multi-user auth/RBAC, Runtime direct sync, Plugin/MCP/sandbox artifact sync, Ticket/Objective/Memory editor integration, or a new Profile source semantic model beyond Decodal/ProfileSourceArchive is required。 + +Validation: +- `cargo test -p yoi-workspace-server` +- `cargo check -p yoi` +- `cd web/workspace && deno task check && deno task test` +- `git diff --check` +- `yoi ticket doctor` +- `nix build .#yoi --no-link` + +Current code map: +- Workspace/profile settings backend: `crates/workspace-server/src/server.rs`, `hosts.rs`, `config.rs`, `resource_broker.rs`, Decodal/archive helpers。 +- Profile archive/config: `crates/worker-runtime/src/profile_archive.rs`, `config_bundle.rs` for source model reference。 +- Frontend settings: `web/workspace/src/routes/w/[workspaceId]/settings/**`, `web/workspace/src/lib/workspace-settings/**`, worker launch profile candidate helpers。 + +Critical risks / reviewer focus: +- No raw paths/secrets/runtime internals or resource handles leak to Browser。 +- Profile editing updates the same Backend discovery/launch options used by Worker launch。 +- Optimistic concurrency and path/symlink escape checks fail closed。 +- Decodal validation is real and not only superficial string checks。 +- Existing Runtime resource fetch / ProfileSourceArchive authority boundaries remain intact。 + +--- + + + +## State changed + +Queued acceptance recorded after previous waiting reason was resolved. + +Checked context: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KX0DSMPT)`: depends_on `00001KWZ5KERY`; target is closed。 +- `TicketOrchestrationPlanQuery(00001KX0DSMPT)`: prior `after 00001KX0G06VA` / waiting note を確認。`00001KX0G06VA` is now closed / merged。 +- Orchestrator worktree git status: clean on `orchestration`。 +- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。 + +Acceptance basis: +- Previous blocker/waiting reason is resolved。 +- concrete missing decision / information は残っていない。 +- Ticket scope is Workspace/Profile settings API and pages using already-merged Decodal/ProfileSourceArchive and resource-fetch boundaries。 +- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 + +--- From 620e37da8b6d5ce018972450288d7b032d100984 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 20:58:26 +0900 Subject: [PATCH 10/18] ticket: route workspace profile settings coder --- .yoi/tickets/00001KX0DSMPT/item.md | 2 +- .yoi/tickets/00001KX0DSMPT/thread.md | 17 +++++++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index 4a55a181..146c8791 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -2,7 +2,7 @@ title: 'Add Workspace settings API and pages for Workspace/Profile editing' state: 'inprogress' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T11:56:36Z' +updated_at: '2026-07-08T11:58:14Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index 19fdecab..7f38f6e6 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -153,3 +153,20 @@ Acceptance basis: - side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 --- + + + +## Implementation report + +Implementation routing update: + +- Worktree-workflow に従い implementation worktree を作成した。 + - Worktree: `/home/hare/Projects/yoi/.worktree/00001KX0DSMPT-workspace-profile-settings` + - Branch: `work/00001KX0DSMPT-workspace-profile-settings` +- Worktree には tracked `.yoi` project records が見えている。 +- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。 +- Sibling Coder Pod `yoi-coder-00001KX0DSMPT-profile-settings` を起動した。 +- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。 +- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。 + +--- From 3abaf51a66fb3d9fe264e59a4f6910f4e249f0eb Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 21:03:33 +0900 Subject: [PATCH 11/18] ticket: record profile settings coder recovery --- .yoi/tickets/00001KX0DSMPT/item.md | 2 +- .yoi/tickets/00001KX0DSMPT/thread.md | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index 146c8791..6832004f 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -2,7 +2,7 @@ title: 'Add Workspace settings API and pages for Workspace/Profile editing' state: 'inprogress' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T11:58:14Z' +updated_at: '2026-07-08T12:03:23Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index 7f38f6e6..b32d6524 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -170,3 +170,15 @@ Implementation routing update: - 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。 --- + + + +## Implementation report + +Implementation routing recovery note: + +- Initial Coder Pod `yoi-coder-00001KX0DSMPT-profile-settings` failed its first turn with provider `context_length_exceeded` before producing implementation output。 +- Ticket remains `inprogress`; worktree/branch already exist and are clean at creation baseline。 +- Recovery plan: avoid `StopPod` and retry with a fresh minimal Coder task that reads the Ticket records from the worktree instead of embedding the full IntentPacket in the prompt. If scope registry prevents a fresh Pod, fallback is to use the existing Pod with a much smaller instruction or ask for human cleanup/authority。 + +--- From 3eefd33334572d9caeaba333730e012cf4403143 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 21:38:23 +0900 Subject: [PATCH 12/18] feat: add workspace profile settings --- .yoi/tickets/00001KX0DSMPT/item.md | 2 +- .yoi/tickets/00001KX0DSMPT/thread.md | 23 + crates/workspace-server/src/companion.rs | 1 + crates/workspace-server/src/hosts.rs | 42 +- crates/workspace-server/src/lib.rs | 1 + .../workspace-server/src/profile_settings.rs | 1191 +++++++++++++++++ crates/workspace-server/src/server.rs | 189 ++- web/workspace/src/lib/workspace-api/http.ts | 25 + .../src/lib/workspace-settings/model.ts | 13 + .../src/lib/workspace-settings/profile-api.ts | 81 ++ .../lib/workspace-settings/profile-types.ts | 60 + .../w/[workspaceId]/settings/+page.svelte | 304 ++++- 12 files changed, 1909 insertions(+), 23 deletions(-) create mode 100644 crates/workspace-server/src/profile_settings.rs create mode 100644 web/workspace/src/lib/workspace-settings/profile-api.ts create mode 100644 web/workspace/src/lib/workspace-settings/profile-types.ts diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index 4a55a181..aa55a61a 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -2,7 +2,7 @@ title: 'Add Workspace settings API and pages for Workspace/Profile editing' state: 'inprogress' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T11:56:36Z' +updated_at: '2026-07-08T12:37:56Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index 19fdecab..63b749d0 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -152,4 +152,27 @@ Acceptance basis: - Ticket scope is Workspace/Profile settings API and pages using already-merged Decodal/ProfileSourceArchive and resource-fetch boundaries。 - side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 +--- + + + +## Implementation report + +Implemented workspace-scoped Workspace/Profile settings in the delegated worktree. + +Summary: +- Added scoped Workspace settings API to read/update display metadata through the workspace identity record with revision checks. +- Added scoped Profile settings API for workspace Decodal Profile registry/source discovery, create/update/delete, default selector updates, typed diagnostics, revision conflict checks, safe source-root/path validation, and Decodal/ProfileSourceArchive validation. +- Added Backend launch-options integration so project profile candidates refresh from the same settings projection; project-profile spawns carry the resolved ConfigBundle through the Runtime resource-fetch/config boundary. +- Added Settings page UI for workspace display name, profile registry/default selector, profile source creation/edit/delete, and safe diagnostics without exposing raw host paths, secrets, runtime endpoints/tokens, socket/session/store paths, archive content/digest, resource handles, or runtime internals. + +Validation: +- git diff --check +- cargo test -p yoi-workspace-server +- cargo check -p yoi +- cd web/workspace && deno task check && deno task test +- yoi ticket doctor +- nix build .#yoi --no-link + + --- diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index 6f97f26e..74b1a0df 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -392,6 +392,7 @@ fn spawn_companion_worker(runtime: &RuntimeRegistry) -> CompanionWorkerState { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ); diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 978a10b1..d49d9002 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -307,6 +307,8 @@ pub struct WorkerSpawnRequest { pub resolved_working_directory_request: Option, #[serde(skip, default)] pub resolved_working_directory: Option, + #[serde(skip, default)] + pub resolved_config_bundle: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -1348,12 +1350,18 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .clone() .unwrap_or_else(|| embedded_profile_selector(&request.intent)); let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone()); - let config_bundle = match default_embedded_config_bundle( - &profile, - &self.host_id, - runtime_id.as_ref(), - &self.resource_broker, - ) + let config_bundle = match request + .resolved_config_bundle + .clone() + .map(Ok) + .unwrap_or_else(|| { + default_embedded_config_bundle( + &profile, + &self.host_id, + runtime_id.as_ref(), + &self.resource_broker, + ) + }) .and_then(|bundle| { self.runtime .store_config_bundle(bundle) @@ -2048,12 +2056,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { .clone() .unwrap_or_else(|| embedded_profile_selector(&request.intent)); let runtime_id = EmbeddedRuntimeId::new(self.runtime_id.clone()); - let sync = match default_embedded_config_bundle( - &profile, - &self.host_id, - runtime_id.as_ref(), - &self.resource_broker, - ) { + let sync = match request + .resolved_config_bundle + .clone() + .map(Ok) + .unwrap_or_else(|| { + default_embedded_config_bundle( + &profile, + &self.host_id, + runtime_id.as_ref(), + &self.resource_broker, + ) + }) { Ok(bundle) => self.sync_config_bundle(bundle), Err(error) => ConfigBundleSyncResult { state: WorkerOperationState::Rejected, @@ -3398,6 +3412,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, } } @@ -3530,6 +3545,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .unwrap(); @@ -3632,6 +3648,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .unwrap(); @@ -3663,6 +3680,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .unwrap(); diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 5507755d..e77a814e 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -8,6 +8,7 @@ pub mod companion; pub mod config; pub mod hosts; pub mod identity; +pub mod profile_settings; pub mod observation; pub mod records; pub mod repositories; diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs new file mode 100644 index 00000000..209f6ecf --- /dev/null +++ b/crates/workspace-server/src/profile_settings.rs @@ -0,0 +1,1191 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::time::UNIX_EPOCH; + +use serde::{Deserialize, Serialize}; +use worker_runtime::config_bundle::{ + ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, +}; +use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; + +use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic}; +use crate::{Error, Result}; + +const PROFILE_REGISTRY_RELATIVE_PATH: &str = ".yoi/profiles.toml"; +const PROFILE_SOURCE_ROOT_RELATIVE_PATH: &str = ".yoi/profiles"; +const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024; +const BUILTIN_PROFILE_IDS: &[&str] = &[ + "builtin:default", + "builtin:companion", + "builtin:intake", + "builtin:orchestrator", + "builtin:coder", + "builtin:reviewer", +]; +const BUILTIN_PROFILE_SLUGS: &[&str] = &[ + "default", + "companion", + "intake", + "orchestrator", + "coder", + "reviewer", +]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceMetadataSettingsResponse { + pub workspace_id: String, + pub display_name: String, + pub created_at: String, + pub revision: String, + pub source: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateWorkspaceMetadataRequest { + pub display_name: String, + pub revision: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceMetadataMutationResponse { + pub workspace: WorkspaceMetadataSettingsResponse, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileSettingsResponse { + pub workspace_id: String, + pub registry_revision: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_profile: Option, + pub profiles: Vec, + pub sources: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSummary { + pub profile_id: String, + pub selector: String, + pub label: String, + pub source_kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub profile_source_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub editable: bool, + pub is_default: bool, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSourceSummary { + pub profile_source_id: String, + pub display_path: String, + pub kind: String, + pub editable: bool, + pub revision: String, + pub size_bytes: u64, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSourceDetailResponse { + pub workspace_id: String, + pub profile: WorkspaceProfileSummary, + pub source: WorkspaceProfileSourceSummary, + pub content: String, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CreateWorkspaceProfileSourceRequest { + pub name: String, + #[serde(default)] + pub description: Option, + pub content: String, + pub registry_revision: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateWorkspaceProfileRegistryRequest { + pub registry_revision: String, + #[serde(default)] + pub default_profile: Option, + pub profiles: Vec, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceProfileRegistryEntryUpdate { + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub profile_source_id: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpdateWorkspaceProfileSourceRequest { + pub content: String, + pub revision: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeleteWorkspaceProfileSourceRequest { + pub registry_revision: String, + pub source_revision: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProfileSettingsMutationResponse { + pub workspace_id: String, + pub settings: ProfileSettingsResponse, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkspaceIdentityFile { + workspace_id: String, + created_at: String, + display_name: String, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProfileRegistryDocument { + #[serde(default)] + default: Option, + #[serde(default)] + profile: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(untagged)] +enum ProfileEntryFile { + Path(String), + Table(ProfileEntryTable), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProfileEntryTable { + path: String, + #[serde(default)] + description: Option, +} + +#[derive(Debug, Clone)] +struct ProjectProfileEntry { + name: String, + description: Option, + relative_path: PathBuf, +} + +pub fn workspace_metadata_settings( + workspace_root: &Path, + fallback_workspace_id: &str, + fallback_created_at: &str, + fallback_display_name: &str, +) -> WorkspaceMetadataSettingsResponse { + let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH); + let mut diagnostics = Vec::new(); + let (workspace_id, created_at, display_name) = match fs::read_to_string(&path) { + Ok(raw) => match toml::from_str::(&raw) { + Ok(file) => (file.workspace_id, file.created_at, file.display_name), + Err(err) => { + diagnostics.push(diagnostic( + "workspace_identity_parse_failed", + DiagnosticSeverity::Error, + format!("Workspace identity could not be parsed: {err}"), + )); + ( + fallback_workspace_id.to_string(), + fallback_created_at.to_string(), + fallback_display_name.to_string(), + ) + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + diagnostics.push(diagnostic( + "workspace_identity_missing", + DiagnosticSeverity::Warning, + "Workspace identity record is missing; showing active backend metadata.", + )); + ( + fallback_workspace_id.to_string(), + fallback_created_at.to_string(), + fallback_display_name.to_string(), + ) + } + Err(err) => { + diagnostics.push(diagnostic( + "workspace_identity_read_failed", + DiagnosticSeverity::Error, + format!("Workspace identity could not be read: {}", sanitize_error(&err.to_string())), + )); + ( + fallback_workspace_id.to_string(), + fallback_created_at.to_string(), + fallback_display_name.to_string(), + ) + } + }; + WorkspaceMetadataSettingsResponse { + workspace_id, + display_name, + created_at, + revision: file_revision(&path), + source: "workspace_identity".to_string(), + diagnostics, + } +} + +pub fn update_workspace_metadata( + workspace_root: &Path, + request: UpdateWorkspaceMetadataRequest, +) -> Result { + let path = workspace_root.join(crate::identity::WORKSPACE_IDENTITY_RELATIVE_PATH); + let current_revision = file_revision(&path); + if request.revision != current_revision { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "workspace_metadata_revision_conflict".to_string(), + message: "Workspace metadata changed before this update was applied".to_string(), + }); + } + let raw = fs::read_to_string(&path)?; + let mut file: WorkspaceIdentityFile = toml::from_str(&raw) + .map_err(|err| Error::Config(format!("failed to parse workspace identity: {err}")))?; + let display_name = sanitize_display_name(&request.display_name)?; + file.display_name = display_name; + let encoded = toml::to_string_pretty(&file) + .map_err(|err| Error::Config(format!("failed to serialize workspace identity: {err}")))?; + fs::write(&path, encoded)?; + Ok(workspace_metadata_settings( + workspace_root, + &file.workspace_id, + &file.created_at, + &file.display_name, + )) +} + +pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> ProfileSettingsResponse { + let mut diagnostics = Vec::new(); + let registry = match read_registry(workspace_root) { + Ok(registry) => registry, + Err(err) => { + diagnostics.push(diagnostic( + "profile_registry_schema_invalid", + DiagnosticSeverity::Error, + err, + )); + ProfileRegistryDocument::default() + } + }; + let registry_revision = file_revision(®istry_path(workspace_root)); + let mut profiles = builtin_profile_summaries(registry.default.as_deref()); + let mut sources = Vec::new(); + let mut seen_selectors = BTreeSet::new(); + for profile in &profiles { + seen_selectors.insert(profile.selector.clone()); + } + for entry in project_entries(®istry, &mut diagnostics) { + let selector = project_selector(&entry.name); + let source_id = project_source_id(&entry.name); + let mut entry_diagnostics = Vec::new(); + if !seen_selectors.insert(selector.clone()) { + entry_diagnostics.push(diagnostic( + "profile_selector_duplicate", + DiagnosticSeverity::Error, + format!("Profile selector '{selector}' is duplicated."), + )); + } + let source_summary = summarize_source(workspace_root, &source_id, &entry.relative_path); + entry_diagnostics.extend(source_summary.diagnostics.clone()); + profiles.push(WorkspaceProfileSummary { + profile_id: selector.clone(), + selector, + label: entry.name.clone(), + source_kind: "project".to_string(), + profile_source_id: Some(source_id.clone()), + description: entry.description.clone(), + editable: true, + is_default: registry.default.as_deref() == Some(project_selector(&entry.name).as_str()), + diagnostics: entry_diagnostics, + }); + sources.push(source_summary); + } + diagnostics.extend(validate_project_profiles(workspace_root, ®istry)); + ProfileSettingsResponse { + workspace_id: workspace_id.to_string(), + registry_revision, + default_profile: registry.default, + profiles, + sources, + diagnostics, + } +} + +pub fn read_profile_source( + workspace_id: &str, + workspace_root: &Path, + source_id: &str, +) -> Result { + let registry = read_registry(workspace_root).map_err(Error::Config)?; + let (name, entry) = entry_for_source_id(®istry, source_id)?; + let full = checked_source_path(workspace_root, &entry.relative_path)?; + let metadata = source_metadata(&full)?; + if metadata.len() > MAX_PROFILE_SOURCE_BYTES { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_too_large".to_string(), + message: "Profile source is too large for browser editing".to_string(), + }); + } + let content = fs::read_to_string(&full)?; + let source = summarize_source(workspace_root, source_id, &entry.relative_path); + let selector = project_selector(&name); + let profile = WorkspaceProfileSummary { + profile_id: selector.clone(), + selector: selector.clone(), + label: name, + source_kind: "project".to_string(), + profile_source_id: Some(source_id.to_string()), + description: entry.description, + editable: true, + is_default: registry.default.as_deref() == Some(selector.as_str()), + diagnostics: source.diagnostics.clone(), + }; + Ok(WorkspaceProfileSourceDetailResponse { + workspace_id: workspace_id.to_string(), + profile, + source, + content, + diagnostics: Vec::new(), + }) +} + +pub fn create_profile_source( + workspace_id: &str, + workspace_root: &Path, + request: CreateWorkspaceProfileSourceRequest, +) -> Result { + let registry_path = registry_path(workspace_root); + ensure_revision(®istry_path, &request.registry_revision, "profile_registry_revision_conflict")?; + let mut registry = read_registry(workspace_root).map_err(Error::Config)?; + let name = validate_profile_name(&request.name)?; + if registry.profile.contains_key(&name) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_selector_duplicate".to_string(), + message: "Profile selector already exists".to_string(), + }); + } + let relative_path = PathBuf::from(".yoi").join("profiles").join(format!("{name}.dcdl")); + validate_source_content(workspace_root, &name, &relative_path, &request.content)?; + let full = checked_source_path(workspace_root, &relative_path)?; + if let Some(parent) = full.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&full, request.content)?; + registry.profile.insert( + name.clone(), + ProfileEntryFile::Table(ProfileEntryTable { + path: format!("profiles/{name}.dcdl"), + description: request.description.and_then(|value| optional_trim(value.as_str())), + }), + ); + write_registry(workspace_root, ®istry)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_settings_updated", + DiagnosticSeverity::Info, + "Profile source was created and profile discovery was refreshed.", + )], + }) +} + +pub fn update_profile_registry( + workspace_id: &str, + workspace_root: &Path, + request: UpdateWorkspaceProfileRegistryRequest, +) -> Result { + let path = registry_path(workspace_root); + ensure_revision(&path, &request.registry_revision, "profile_registry_revision_conflict")?; + validate_default_profile(request.default_profile.as_deref())?; + let mut profile = BTreeMap::new(); + let mut seen = BTreeSet::new(); + for update in request.profiles { + let name = validate_profile_name(&update.name)?; + if !seen.insert(name.clone()) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_selector_duplicate".to_string(), + message: "Profile selector duplicate in registry update".to_string(), + }); + } + let source_id = update + .profile_source_id + .as_deref() + .unwrap_or(project_source_id(&name).as_str()) + .to_string(); + let (source_name, _) = parse_project_source_id(&source_id)?; + if source_name != name { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_id_mismatch".to_string(), + message: "Profile source id must match its registry selector".to_string(), + }); + } + profile.insert( + name.clone(), + ProfileEntryFile::Table(ProfileEntryTable { + path: format!("profiles/{name}.dcdl"), + description: update.description.and_then(|value| optional_trim(value.as_str())), + }), + ); + } + let registry = ProfileRegistryDocument { + default: request.default_profile, + profile, + }; + for entry in project_entries(®istry, &mut Vec::new()) { + let full = checked_source_path(workspace_root, &entry.relative_path)?; + if full.exists() { + let content = fs::read_to_string(&full)?; + validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content)?; + } + } + write_registry(workspace_root, ®istry)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_registry_updated", + DiagnosticSeverity::Info, + "Profile registry was updated and profile discovery was refreshed.", + )], + }) +} + +pub fn update_profile_source( + workspace_id: &str, + workspace_root: &Path, + source_id: &str, + request: UpdateWorkspaceProfileSourceRequest, +) -> Result { + let registry = read_registry(workspace_root).map_err(Error::Config)?; + let (name, entry) = entry_for_source_id(®istry, source_id)?; + let full = checked_source_path(workspace_root, &entry.relative_path)?; + ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?; + validate_source_content(workspace_root, &name, &entry.relative_path, &request.content)?; + fs::write(&full, request.content)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_source_updated", + DiagnosticSeverity::Info, + "Profile source was updated and profile discovery was refreshed.", + )], + }) +} + +pub fn delete_profile_source( + workspace_id: &str, + workspace_root: &Path, + source_id: &str, + request: DeleteWorkspaceProfileSourceRequest, +) -> Result { + let registry_path = registry_path(workspace_root); + ensure_revision(®istry_path, &request.registry_revision, "profile_registry_revision_conflict")?; + let mut registry = read_registry(workspace_root).map_err(Error::Config)?; + let (name, entry) = entry_for_source_id(®istry, source_id)?; + let full = checked_source_path(workspace_root, &entry.relative_path)?; + ensure_revision(&full, &request.source_revision, "profile_source_revision_conflict")?; + registry.profile.remove(&name); + if registry.default.as_deref() == Some(project_selector(&name).as_str()) { + registry.default = None; + } + if full.exists() { + fs::remove_file(&full)?; + } + write_registry(workspace_root, ®istry)?; + Ok(ProfileSettingsMutationResponse { + workspace_id: workspace_id.to_string(), + settings: load_profile_settings(workspace_id, workspace_root), + diagnostics: vec![diagnostic( + "profile_source_deleted", + DiagnosticSeverity::Info, + "Profile source and registry entry were deleted and profile discovery was refreshed.", + )], + }) +} + +pub fn build_workspace_profile_archive( + workspace_root: &Path, + selector: &str, +) -> Result> { + if !selector.starts_with("project:") { + return Ok(None); + } + let registry = read_registry(workspace_root).map_err(Error::Config)?; + let mut entrypoints = BTreeMap::new(); + let mut sources = BTreeMap::new(); + for entry in project_entries(®istry, &mut Vec::new()) { + let path = archive_path_for_entry(&entry.name); + let full = checked_source_path(workspace_root, &entry.relative_path)?; + let content = fs::read_to_string(&full)?; + sources.insert(path.clone(), content); + entrypoints.insert(project_selector(&entry.name), path); + } + if !entrypoints.contains_key(selector) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "unknown_profile_selector".to_string(), + message: "Selected project profile is not present in the workspace profile registry".to_string(), + }); + } + if let Some(default) = registry.default.as_deref().filter(|value| value.starts_with("project:")) { + if let Some(path) = entrypoints.get(default).cloned() { + entrypoints.insert("default".to_string(), path); + } + } + let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: "workspace-project-decodal-profiles-v1".to_string(), + entrypoints, + imports: BTreeMap::new(), + sources, + }) + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_archive_invalid".to_string(), + message: err.to_string(), + })?; + archive.verify().and_then(|verified| { + verified + .resolve_profile(selector, workspace_root, "workspace-settings-validation") + .map(|_| ()) + }).map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_invalid".to_string(), + message: err.to_string(), + })?; + Ok(Some(archive)) +} + +pub fn build_workspace_profile_config_bundle( + workspace_root: &Path, + workspace_id: &str, + workspace_created_at: &str, + selector: &str, +) -> Result> { + let Some(archive) = build_workspace_profile_archive(workspace_root, selector)? else { + return Ok(None); + }; + let bundle = ConfigBundle { + metadata: ConfigBundleMetadata { + id: "workspace-project-profile-settings-v1".to_string(), + digest: String::new(), + revision: file_revision(®istry_path(workspace_root)), + workspace_id: workspace_id.to_string(), + created_at: workspace_created_at.to_string(), + provenance: ConfigBundleProvenance { + source: "workspace_profile_settings".to_string(), + detail: Some("workspace Decodal profile registry".to_string()), + }, + }, + profiles: vec![ConfigProfileDescriptor { + selector: worker_runtime::catalog::ProfileSelector::Named(selector.to_string()), + label: Some(selector.to_string()), + }], + declarations: Vec::new(), + profile_source_archive: Some(archive), + profile_source_archive_handle: None, + } + .with_computed_digest(); + Ok(Some(bundle)) +} + +pub fn project_profile_candidates(workspace_root: &Path) -> Vec { + load_profile_settings("workspace", workspace_root) + .profiles + .into_iter() + .filter(|profile| profile.source_kind == "project") + .collect() +} + +pub fn is_profile_candidate(workspace_root: &Path, profile_id: &str) -> bool { + BUILTIN_PROFILE_IDS.contains(&profile_id) + || profile_id == "runtime_default" + || project_profile_candidates(workspace_root) + .into_iter() + .any(|profile| profile.profile_id == profile_id) +} + +fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec { + let labels = [ + ("builtin:default", "Default", "Bundled default Yoi profile"), + ("builtin:companion", "Companion", "Bundled Companion role profile"), + ("builtin:intake", "Intake", "Bundled Intake role profile"), + ("builtin:orchestrator", "Orchestrator", "Bundled Orchestrator role profile"), + ("builtin:coder", "Coder", "Bundled Coder role profile"), + ("builtin:reviewer", "Reviewer", "Bundled Reviewer role profile"), + ]; + labels + .into_iter() + .map(|(id, label, description)| WorkspaceProfileSummary { + profile_id: id.to_string(), + selector: id.to_string(), + label: label.to_string(), + source_kind: "builtin".to_string(), + profile_source_id: None, + description: Some(description.to_string()), + editable: false, + is_default: default_profile == Some(id), + diagnostics: Vec::new(), + }) + .collect() +} + +fn validate_project_profiles(workspace_root: &Path, registry: &ProfileRegistryDocument) -> Vec { + let mut diagnostics = Vec::new(); + for entry in project_entries(registry, &mut diagnostics) { + let full = match checked_source_path(workspace_root, &entry.relative_path) { + Ok(path) => path, + Err(err) => { + diagnostics.push(diagnostic( + "profile_source_path_escape", + DiagnosticSeverity::Error, + err.to_string(), + )); + continue; + } + }; + match fs::read_to_string(&full) { + Ok(content) => { + if let Err(err) = validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content) { + diagnostics.push(diagnostic( + "profile_source_invalid", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => diagnostics.push(diagnostic( + "profile_source_missing", + DiagnosticSeverity::Error, + format!("Profile source '{}' is missing.", entry.name), + )), + Err(err) => diagnostics.push(diagnostic( + "profile_source_read_failed", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )), + } + } + if let Some(default) = registry.default.as_deref() { + if let Err(err) = validate_default_profile(Some(default)) { + diagnostics.push(diagnostic( + "profile_default_invalid", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )); + } + } + diagnostics +} + +fn validate_source_content( + workspace_root: &Path, + name: &str, + relative_path: &Path, + content: &str, +) -> Result<()> { + if content.as_bytes().len() as u64 > MAX_PROFILE_SOURCE_BYTES { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_too_large".to_string(), + message: "Profile source exceeds the browser editing size limit".to_string(), + }); + } + let archive_path = archive_path_for_entry(name); + let selector = project_selector(name); + let mut entrypoints = BTreeMap::new(); + entrypoints.insert(selector.clone(), archive_path.clone()); + entrypoints.insert("default".to_string(), archive_path.clone()); + let mut sources = BTreeMap::new(); + sources.insert(archive_path, content.to_string()); + let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: format!("workspace-profile-validation-{name}"), + entrypoints, + imports: BTreeMap::new(), + sources, + }) + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_archive_invalid".to_string(), + message: err.to_string(), + })?; + let verified = archive.verify().map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_archive_invalid".to_string(), + message: err.to_string(), + })?; + checked_source_path(workspace_root, relative_path)?; + verified + .resolve_profile(&selector, workspace_root, "workspace-settings-validation") + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_syntax_invalid".to_string(), + message: err.to_string(), + })?; + Ok(()) +} + +fn summarize_source( + workspace_root: &Path, + source_id: &str, + relative_path: &Path, +) -> WorkspaceProfileSourceSummary { + let mut diagnostics = Vec::new(); + let display_path = display_source_path(relative_path); + let mut revision = "missing".to_string(); + let mut size_bytes = 0; + match checked_source_path(workspace_root, relative_path) { + Ok(full) => match source_metadata(&full) { + Ok(metadata) => { + size_bytes = metadata.len(); + revision = file_revision(&full); + if size_bytes > MAX_PROFILE_SOURCE_BYTES { + diagnostics.push(diagnostic( + "profile_source_too_large", + DiagnosticSeverity::Error, + "Profile source is too large for browser editing.", + )); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => diagnostics.push(diagnostic( + "profile_source_missing", + DiagnosticSeverity::Error, + "Profile source file is missing.", + )), + Err(err) => diagnostics.push(diagnostic( + "profile_source_metadata_failed", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )), + }, + Err(err) => diagnostics.push(diagnostic( + "profile_source_path_escape", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )), + } + WorkspaceProfileSourceSummary { + profile_source_id: source_id.to_string(), + display_path, + kind: "decodal".to_string(), + editable: diagnostics.iter().all(|d| d.severity != DiagnosticSeverity::Error), + revision, + size_bytes, + diagnostics, + } +} + +fn read_registry(workspace_root: &Path) -> std::result::Result { + let path = registry_path(workspace_root); + match fs::read_to_string(&path) { + Ok(raw) => toml::from_str(&raw).map_err(|err| format!("invalid profile registry schema: {err}")), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(ProfileRegistryDocument::default()), + Err(err) => Err(format!("failed to read profile registry: {}", sanitize_error(&err.to_string()))), + } +} + +fn write_registry(workspace_root: &Path, registry: &ProfileRegistryDocument) -> Result<()> { + let path = registry_path(workspace_root); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let raw = toml::to_string_pretty(registry) + .map_err(|err| Error::Config(format!("failed to serialize profile registry: {err}")))?; + fs::write(path, raw)?; + Ok(()) +} + +fn project_entries(registry: &ProfileRegistryDocument, diagnostics: &mut Vec) -> Vec { + registry + .profile + .iter() + .filter_map(|(name, entry)| match validate_profile_name(name) { + Ok(name) => { + if BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { + diagnostics.push(diagnostic( + "profile_selector_duplicate", + DiagnosticSeverity::Error, + format!("Project profile '{name}' conflicts with a builtin selector."), + )); + } + let (path, description) = match entry { + ProfileEntryFile::Path(path) => (path.clone(), None), + ProfileEntryFile::Table(table) => (table.path.clone(), table.description.clone()), + }; + match registry_relative_source_path(&path) { + Ok(relative_path) => Some(ProjectProfileEntry { + name, + description, + relative_path, + }), + Err(err) => { + diagnostics.push(diagnostic( + "profile_source_path_escape", + DiagnosticSeverity::Error, + err, + )); + None + } + } + } + Err(err) => { + diagnostics.push(diagnostic( + "profile_selector_invalid", + DiagnosticSeverity::Error, + err.to_string(), + )); + None + } + }) + .collect() +} + +fn entry_for_source_id(registry: &ProfileRegistryDocument, source_id: &str) -> Result<(String, ProjectProfileEntry)> { + let (name, _) = parse_project_source_id(source_id)?; + let mut diagnostics = Vec::new(); + let entry = project_entries(registry, &mut diagnostics) + .into_iter() + .find(|entry| entry.name == name) + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "unknown_profile_source".to_string(), + message: "Unknown profile source id".to_string(), + })?; + Ok((name, entry)) +} + +fn registry_relative_source_path(raw: &str) -> std::result::Result { + let path = Path::new(raw); + if path.is_absolute() { + return Err("Profile source path must be workspace-relative and safe.".to_string()); + } + let path = if path.starts_with(".yoi") { + path.to_path_buf() + } else { + PathBuf::from(".yoi").join(path) + }; + validate_relative_source_path(&path)?; + Ok(path) +} + +fn validate_relative_source_path(path: &Path) -> std::result::Result<(), String> { + if !path.starts_with(PROFILE_SOURCE_ROOT_RELATIVE_PATH) { + return Err("Profile source path must be under the workspace profile source root.".to_string()); + } + for component in path.components() { + if !matches!(component, Component::Normal(_)) { + return Err("Profile source path must not contain absolute, parent, or prefix components.".to_string()); + } + } + if path.extension().and_then(|value| value.to_str()) != Some("dcdl") { + return Err("Profile source path must use the .dcdl extension.".to_string()); + } + Ok(()) +} + +fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result { + validate_relative_source_path(relative_path).map_err(|message| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_path_escape".to_string(), + message, + })?; + let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH); + fs::create_dir_all(&source_root)?; + let canonical_root = fs::canonicalize(&source_root)?; + let full = workspace_root.join(relative_path); + if let Ok(canonical_full) = fs::canonicalize(&full) { + if !canonical_full.starts_with(&canonical_root) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_symlink_escape".to_string(), + message: "Profile source resolves outside the workspace profile source root".to_string(), + }); + } + } else if let Some(parent) = full.parent() { + let canonical_parent = fs::canonicalize(parent)?; + if !canonical_parent.starts_with(&canonical_root) { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_path_escape".to_string(), + message: "Profile source parent resolves outside the workspace profile source root".to_string(), + }); + } + } + Ok(full) +} + +fn validate_default_profile(value: Option<&str>) -> Result<()> { + if let Some(value) = value { + if BUILTIN_PROFILE_IDS.contains(&value) || value.starts_with("project:") { + return Ok(()); + } + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_default_invalid".to_string(), + message: "Default profile must be a Backend-published builtin or project selector".to_string(), + }); + } + Ok(()) +} + +fn validate_profile_name(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.len() > 64 + || !trimmed + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_')) + { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_selector_invalid".to_string(), + message: "Profile selector must contain only ASCII letters, digits, '-' or '_'".to_string(), + }); + } + Ok(trimmed.to_string()) +} + +fn sanitize_display_name(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.chars().any(char::is_control) || trimmed.len() > 120 { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "workspace_display_name_invalid".to_string(), + message: "Workspace display name must be non-empty, bounded, and must not contain control characters".to_string(), + }); + } + Ok(trimmed.to_string()) +} + +fn parse_project_source_id(source_id: &str) -> Result<(String, String)> { + let Some(name) = source_id.strip_prefix("project:") else { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "unsupported_profile_source_id".to_string(), + message: "Profile source id is not a project profile source".to_string(), + }); + }; + let name = validate_profile_name(name)?; + Ok((name.clone(), project_source_id(&name))) +} + +pub fn project_selector(name: &str) -> String { + format!("project:{name}") +} + +pub fn project_source_id(name: &str) -> String { + format!("project:{name}") +} + +pub fn selector_for_builtin_candidate(id: &str) -> Option { + match id { + "runtime_default" => Some(worker_runtime::catalog::ProfileSelector::RuntimeDefault), + "builtin:default" | "builtin:companion" | "builtin:intake" | "builtin:orchestrator" + | "builtin:coder" | "builtin:reviewer" => { + Some(worker_runtime::catalog::ProfileSelector::Builtin(id.to_string())) + } + value if value.starts_with("project:") => { + Some(worker_runtime::catalog::ProfileSelector::Named(value.to_string())) + } + _ => None, + } +} + +fn archive_path_for_entry(name: &str) -> String { + format!("profiles/{name}.dcdl") +} + +fn display_source_path(relative_path: &Path) -> String { + relative_path + .strip_prefix(".yoi") + .unwrap_or(relative_path) + .to_string_lossy() + .replace('\\', "/") +} + +fn registry_path(workspace_root: &Path) -> PathBuf { + workspace_root.join(PROFILE_REGISTRY_RELATIVE_PATH) +} + +fn file_revision(path: &Path) -> String { + let Ok(metadata) = fs::metadata(path) else { + return "missing".to_string(); + }; + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("rev:{modified}:{}", metadata.len()) +} + +fn source_metadata(path: &Path) -> std::io::Result { + fs::symlink_metadata(path) +} + +fn ensure_revision(path: &Path, expected: &str, code: &'static str) -> Result<()> { + let actual = file_revision(path); + if expected != actual { + return Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: code.to_string(), + message: "Settings changed before this update was applied".to_string(), + }); + } + Ok(()) +} + +fn optional_trim(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn diagnostic( + code: impl Into, + severity: DiagnosticSeverity, + message: impl Into, +) -> RuntimeDiagnostic { + RuntimeDiagnostic { + code: code.into(), + severity, + message: message.into(), + } +} + +fn sanitize_error(value: &str) -> String { + value + .split_whitespace() + .map(|token| { + if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions") { + "" + } else { + token + } + }) + .collect::>() + .join(" ") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn valid_decodal(slug: &str) -> String { + format!( + r#"{{ + slug = "{slug}"; + description = "Test"; + scope = "workspace_read"; + }}"# + ) + } + + #[test] + fn profile_settings_create_update_and_discover_project_profile() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let created = create_profile_source( + "workspace-test", + dir.path(), + CreateWorkspaceProfileSourceRequest { + name: "alpha".to_string(), + description: Some("Alpha".to_string()), + content: valid_decodal("alpha"), + registry_revision: revision, + }, + ) + .unwrap(); + assert!(created + .settings + .profiles + .iter() + .any(|profile| profile.profile_id == "project:alpha")); + assert!(build_workspace_profile_archive(dir.path(), "project:alpha") + .unwrap() + .is_some()); + } + + #[test] + fn profile_source_rejects_path_escape_and_revision_conflict() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write( + dir.path().join(".yoi/profiles.toml"), + "[profile.bad]\npath = \"../bad.dcdl\"\n", + ) + .unwrap(); + let settings = load_profile_settings("workspace-test", dir.path()); + assert!(settings + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "profile_source_path_escape")); + + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: "stale".to_string(), + default_profile: None, + profiles: Vec::new(), + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("profile_registry_revision_conflict")); + } + + #[test] + fn profile_source_rejects_invalid_decodal() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let err = create_profile_source( + "workspace-test", + dir.path(), + CreateWorkspaceProfileSourceRequest { + name: "bad".to_string(), + description: None, + content: "not decodal".to_string(), + registry_revision: revision, + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("profile_source_syntax_invalid") || err.to_string().contains("profile_source_archive_invalid")); + } +} diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a34f1c80..773886d7 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6,7 +6,7 @@ use axum::extract::{Path as AxumPath, Query, State}; use axum::http::header::{CONTENT_TYPE, LOCATION}; use axum::http::{StatusCode, Uri}; use axum::response::{IntoResponse, Response}; -use axum::routing::{delete, get, post}; +use axum::routing::{delete, get, post, put}; use axum::{Json, Router}; use chrono::{SecondsFormat, Utc}; use futures::StreamExt; @@ -36,6 +36,11 @@ use crate::observation::{ BackendObservationProxy, ClientWorkerEventWsFrame, ClientWorkerEventsWsQuery, ObservationProxyError, RuntimeObservationClient, RuntimeObservationSourceConfig, }; +use crate::profile_settings::{ + CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest, + UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest, + UpdateWorkspaceProfileSourceRequest, +}; use crate::records::{ LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, }; @@ -264,6 +269,24 @@ pub fn build_router(api: WorkspaceApi) -> Router { Router::new() .route("/api/workspace", get(get_workspace)) .route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace)) + .route( + "/api/w/{workspace_id}/settings/workspace", + get(scoped_get_workspace_settings).put(scoped_update_workspace_settings), + ) + .route( + "/api/w/{workspace_id}/settings/profiles", + get(scoped_get_profile_settings).post(scoped_create_profile_source), + ) + .route( + "/api/w/{workspace_id}/settings/profiles/registry", + put(scoped_update_profile_registry), + ) + .route( + "/api/w/{workspace_id}/settings/profiles/{profile_source_id}", + get(scoped_get_profile_source) + .put(scoped_update_profile_source) + .delete(scoped_delete_profile_source), + ) .route("/api/tickets", get(list_tickets)) .route("/api/w/{workspace_id}/tickets", get(scoped_list_tickets)) .route("/api/tickets/{id}", get(get_ticket)) @@ -814,6 +837,113 @@ async fn scoped_get_workspace( get_workspace(State(api)).await } +async fn scoped_get_workspace_settings( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(crate::profile_settings::workspace_metadata_settings( + &api.config.workspace_root, + &api.config.workspace_id, + &api.config.workspace_created_at, + &api.config.workspace_display_name, + ))) +} + +async fn scoped_update_workspace_settings( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let workspace = crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?; + Ok(Json(crate::profile_settings::WorkspaceMetadataMutationResponse { + workspace, + diagnostics: vec![RuntimeDiagnostic { + code: "workspace_metadata_updated".to_string(), + severity: DiagnosticSeverity::Info, + message: "Workspace display metadata was updated.".to_string(), + }], + })) +} + +async fn scoped_get_profile_settings( + State(api): State, + AxumPath(path): AxumPath, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(crate::profile_settings::load_profile_settings( + &api.config.workspace_id, + &api.config.workspace_root, + ))) +} + +async fn scoped_create_profile_source( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(crate::profile_settings::create_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + request, + )?)) +} + +async fn scoped_update_profile_registry( + State(api): State, + AxumPath(path): AxumPath, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(crate::profile_settings::update_profile_registry( + &api.config.workspace_id, + &api.config.workspace_root, + request, + )?)) +} + +async fn scoped_get_profile_source( + State(api): State, + AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::read_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + &profile_source_id, + )?)) +} + +async fn scoped_update_profile_source( + State(api): State, + AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::update_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + &profile_source_id, + request, + )?)) +} + +async fn scoped_delete_profile_source( + State(api): State, + AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::delete_profile_source( + &api.config.workspace_id, + &api.config.workspace_root, + &profile_source_id, + request, + )?)) +} + async fn scoped_list_tickets( State(api): State, AxumPath(path): AxumPath, @@ -1639,12 +1769,22 @@ async fn create_workspace_worker( State(api): State, Json(request): Json, ) -> ApiResult> { - let profile_selector = profile_selector_for_candidate(&request.profile).ok_or_else(|| { + let profile_selector = profile_selector_for_candidate_with_root(&api.config.workspace_root, &request.profile).ok_or_else(|| { settings_bad_request( "unsupported_worker_profile", "profile must be selected from Backend-published worker profile candidates", ) })?; + let resolved_config_bundle = if request.profile.starts_with("project:") { + crate::profile_settings::build_workspace_profile_config_bundle( + &api.config.workspace_root, + &api.config.workspace_id, + &api.config.workspace_created_at, + &request.profile, + )? + } else { + None + }; let display_name = sanitize_worker_display_name(&request.display_name).ok_or_else(|| { settings_bad_request( "invalid_worker_display_name", @@ -1702,6 +1842,7 @@ async fn create_workspace_worker( working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory, + resolved_config_bundle, }, ) .map_err(|err| err.into_error())?; @@ -2753,7 +2894,7 @@ fn worker_launch_options_response(api: &WorkspaceApi) -> WorkerLaunchOptionsResp WorkerLaunchOptionsResponse { workspace_id: api.config.workspace_id.clone(), runtimes, - profiles: worker_profile_candidates(), + profiles: worker_profile_candidates_for_root(&api.config.workspace_root), repositories: working_directory_repository_options(api), working_directories: working_directory_summaries(api).unwrap_or_default(), diagnostics: Vec::new(), @@ -2823,8 +2964,16 @@ fn working_directory_request_for_browser( }) } +#[cfg(test)] fn worker_profile_candidates() -> Vec { - vec![ + worker_profile_candidates_for_root(Path::new(".")) + .into_iter() + .filter(|candidate| candidate.id == "builtin:coder" || candidate.id == "runtime_default") + .collect() +} + +fn worker_profile_candidates_for_root(workspace_root: &Path) -> Vec { + let mut candidates = vec![ WorkerLaunchProfileCandidate { id: "builtin:coder".to_string(), label: "Coding Worker".to_string(), @@ -2835,14 +2984,35 @@ fn worker_profile_candidates() -> Vec { label: "Runtime default".to_string(), description: "Use the selected Runtime's default profile.".to_string(), }, - ] + ]; + candidates.extend( + crate::profile_settings::project_profile_candidates(workspace_root) + .into_iter() + .filter(|profile| profile.diagnostics.is_empty()) + .map(|profile| WorkerLaunchProfileCandidate { + id: profile.profile_id, + label: profile.label, + description: profile + .description + .unwrap_or_else(|| "Workspace Decodal profile source.".to_string()), + }), + ); + candidates } fn profile_selector_for_candidate(profile: &str) -> Option { - match profile { - "builtin:coder" => Some(ProfileSelector::Builtin("builtin:coder".to_string())), - "runtime_default" => Some(ProfileSelector::RuntimeDefault), - _ => None, + crate::profile_settings::selector_for_builtin_candidate(profile).filter(|_| { + matches!(profile, "builtin:coder" | "runtime_default") + }) +} + +fn profile_selector_for_candidate_with_root(workspace_root: &Path, profile: &str) -> Option { + if profile_selector_for_candidate(profile).is_some() { + profile_selector_for_candidate(profile) + } else if crate::profile_settings::is_profile_candidate(workspace_root, profile) { + crate::profile_settings::selector_for_builtin_candidate(profile) + } else { + None } } @@ -4599,6 +4769,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, + resolved_config_bundle: None, }, ) .expect("spawn worker"); diff --git a/web/workspace/src/lib/workspace-api/http.ts b/web/workspace/src/lib/workspace-api/http.ts index 92365eae..b519c8c2 100644 --- a/web/workspace/src/lib/workspace-api/http.ts +++ b/web/workspace/src/lib/workspace-api/http.ts @@ -38,6 +38,31 @@ export async function loadJson( } } +async function requireJson(response: Response, path: string): Promise { + if (!response.ok) { + const text = await response.text(); + throw new Error(text || `${path} request failed (${response.status})`); + } + return (await response.json()) as T; +} + +export async function workspaceApiJson(path: string): Promise { + return requireJson(await fetch(path), path); +} + +export async function workspaceApiJsonWithBody(path: string, init: RequestInit): Promise { + return requireJson( + await fetch(path, { + headers: { + "content-type": "application/json", + ...(init.headers ?? {}), + }, + ...init, + }), + path, + ); +} + export function formatDate(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) { diff --git a/web/workspace/src/lib/workspace-settings/model.ts b/web/workspace/src/lib/workspace-settings/model.ts index c692244e..8c0b4689 100644 --- a/web/workspace/src/lib/workspace-settings/model.ts +++ b/web/workspace/src/lib/workspace-settings/model.ts @@ -6,6 +6,7 @@ export type Diagnostic = { export type SettingsSectionId = | "runtime-connections" + | "profile-sources" | "backend-config" | "workspace-identity"; @@ -84,6 +85,18 @@ export const SETTINGS_SECTIONS: readonly SettingsSection[] = [ "Test negotiation is an observation only; checked_at, health, compatibility, and capability results are not persisted to local config.", ], }, + { + id: "profile-sources", + label: "Profile Sources", + status: "editable", + summary: + "Manage the workspace-scoped Decodal Profile registry and source files used by Backend-published launch profile discovery.", + bullets: [ + "Selectors are source-qualified (builtin:* or project:*); raw profile source paths, archive content, archive digests, resource handles, and runtime tokens are not exposed.", + "Profile source edits are validated through the Backend ProfileSourceArchive/Decodal boundary before they are persisted.", + "Launch profile candidates refresh from the same Backend projection after registry or source updates.", + ], + }, { id: "backend-config", label: "Backend Config", diff --git a/web/workspace/src/lib/workspace-settings/profile-api.ts b/web/workspace/src/lib/workspace-settings/profile-api.ts new file mode 100644 index 00000000..79141333 --- /dev/null +++ b/web/workspace/src/lib/workspace-settings/profile-api.ts @@ -0,0 +1,81 @@ +import { workspaceApiJson, workspaceApiJsonWithBody } from "../workspace-api/http"; +import type { + ProfileSettingsMutationResponse, + ProfileSettingsResponse, + WorkspaceMetadataMutationResponse, + WorkspaceMetadataSettingsResponse, + WorkspaceProfileSourceDetailResponse, +} from "./profile-types"; + +export function fetchWorkspaceMetadataSettings(workspaceId: string): Promise { + return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`); +} + +export function updateWorkspaceMetadataSettings( + workspaceId: string, + request: { display_name: string; revision: string }, +): Promise { + return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/workspace`, { + method: "PUT", + body: JSON.stringify(request), + }); +} + +export function fetchProfileSettings(workspaceId: string): Promise { + return workspaceApiJson(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`); +} + +export function createProfileSource( + workspaceId: string, + request: { name: string; description?: string; content: string; registry_revision: string }, +): Promise { + return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, { + method: "POST", + body: JSON.stringify(request), + }); +} + +export function updateProfileRegistry( + workspaceId: string, + request: { + registry_revision: string; + default_profile?: string | null; + profiles: Array<{ name: string; description?: string | null; profile_source_id?: string | null }>; + }, +): Promise { + return workspaceApiJsonWithBody(`/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/registry`, { + method: "PUT", + body: JSON.stringify(request), + }); +} + +export function fetchProfileSource( + workspaceId: string, + sourceId: string, +): Promise { + return workspaceApiJson( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`, + ); +} + +export function updateProfileSource( + workspaceId: string, + sourceId: string, + request: { content: string; revision: string }, +): Promise { + return workspaceApiJsonWithBody( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`, + { method: "PUT", body: JSON.stringify(request) }, + ); +} + +export function deleteProfileSource( + workspaceId: string, + sourceId: string, + request: { registry_revision: string; source_revision: string }, +): Promise { + return workspaceApiJsonWithBody( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/${encodeURIComponent(sourceId)}`, + { method: "DELETE", body: JSON.stringify(request) }, + ); +} diff --git a/web/workspace/src/lib/workspace-settings/profile-types.ts b/web/workspace/src/lib/workspace-settings/profile-types.ts new file mode 100644 index 00000000..d421f2f6 --- /dev/null +++ b/web/workspace/src/lib/workspace-settings/profile-types.ts @@ -0,0 +1,60 @@ +import type { Diagnostic } from "./model"; + +export type WorkspaceMetadataSettingsResponse = { + workspace_id: string; + display_name: string; + created_at: string; + revision: string; + source: string; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceMetadataMutationResponse = { + workspace: WorkspaceMetadataSettingsResponse; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSummary = { + profile_id: string; + selector: string; + label: string; + source_kind: "builtin" | "project" | string; + profile_source_id?: string | null; + description?: string | null; + editable: boolean; + is_default: boolean; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceSummary = { + profile_source_id: string; + display_path: string; + kind: "decodal" | string; + editable: boolean; + revision: string; + size_bytes: number; + diagnostics: Diagnostic[]; +}; + +export type ProfileSettingsResponse = { + workspace_id: string; + registry_revision: string; + default_profile?: string | null; + profiles: WorkspaceProfileSummary[]; + sources: WorkspaceProfileSourceSummary[]; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceDetailResponse = { + workspace_id: string; + profile: WorkspaceProfileSummary; + source: WorkspaceProfileSourceSummary; + content: string; + diagnostics: Diagnostic[]; +}; + +export type ProfileSettingsMutationResponse = { + workspace_id: string; + settings: ProfileSettingsResponse; + diagnostics: Diagnostic[]; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte index 1737a2bc..4c7faefb 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte @@ -13,6 +13,21 @@ type RuntimeConnectionMutationResponse, type RuntimeConnectionSettingsResponse, } from "$lib/workspace-settings/model"; + import type { + ProfileSettingsResponse, + WorkspaceMetadataSettingsResponse, + WorkspaceProfileSourceDetailResponse, + } from "$lib/workspace-settings/profile-types"; + import { + createProfileSource, + deleteProfileSource, + fetchProfileSettings, + fetchProfileSource, + fetchWorkspaceMetadataSettings, + updateProfileRegistry, + updateProfileSource, + updateWorkspaceMetadataSettings, + } from "$lib/workspace-settings/profile-api"; type RemoteAddForm = { runtime_id: string; @@ -44,6 +59,19 @@ display_name: "", endpoint: "", }); + let workspaceMetadata = $state(null); + let profileSettings = $state(null); + let selectedProfileSource = $state(null); + let profileSourceContent = $state(""); + let newProfileName = $state(""); + let newProfileDescription = $state(""); + let newProfileContent = $state("{\n slug = \"workspace-profile\";\n description = \"Workspace profile\";\n scope = \"workspace_read\";\n}\n"); + let profileLoading = $state(true); + let profileMessage = $state(null); + let profileDiagnostics = $state([]); + let profileSubmitting = $state(false); + let workspaceNameDraft = $state(""); + let defaultProfileDraft = $state(""); $effect(() => { if (!workspaceId) { @@ -83,6 +111,179 @@ }; }); + $effect(() => { + if (!workspaceId) { + profileLoading = false; + return; + } + let cancelled = false; + async function loadProfileAndWorkspaceSettings() { + profileLoading = true; + profileMessage = null; + profileDiagnostics = []; + try { + const [workspace, profiles] = await Promise.all([ + fetchWorkspaceMetadataSettings(workspaceId), + fetchProfileSettings(workspaceId), + ]); + if (!cancelled) { + workspaceMetadata = workspace; + workspaceNameDraft = workspace.display_name; + profileSettings = profiles; + defaultProfileDraft = profiles.default_profile ?? ""; + profileDiagnostics = [...workspace.diagnostics, ...profiles.diagnostics]; + } + } catch (err) { + if (!cancelled) { + profileMessage = err instanceof Error ? err.message : "profile settings request failed"; + } + } finally { + if (!cancelled) { + profileLoading = false; + } + } + } + loadProfileAndWorkspaceSettings(); + return () => { + cancelled = true; + }; + }); + + async function refreshProfileSettings() { + profileSettings = await fetchProfileSettings(workspaceId); + profileDiagnostics = profileSettings.diagnostics; + } + + async function submitWorkspaceName() { + if (!workspaceMetadata) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await updateWorkspaceMetadataSettings(workspaceId, { + display_name: workspaceNameDraft, + revision: workspaceMetadata.revision, + }); + workspaceMetadata = response.workspace; + workspaceNameDraft = response.workspace.display_name; + profileDiagnostics = response.diagnostics.concat(response.workspace.diagnostics); + profileMessage = "Workspace display name updated."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "workspace update failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitProfileRegistry() { + if (!profileSettings) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await updateProfileRegistry(workspaceId, { + registry_revision: profileSettings.registry_revision, + default_profile: defaultProfileDraft || null, + profiles: profileSettings.profiles + .filter((profile) => profile.source_kind === "project") + .map((profile) => ({ + name: profile.selector.replace(/^project:/, ""), + description: profile.description ?? null, + profile_source_id: profile.profile_source_id ?? null, + })), + }); + profileSettings = response.settings; + defaultProfileDraft = response.settings.default_profile ?? ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + profileMessage = "Profile registry updated."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile registry update failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitNewProfileSource() { + if (!profileSettings) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await createProfileSource(workspaceId, { + name: newProfileName, + description: newProfileDescription || undefined, + content: newProfileContent, + registry_revision: profileSettings.registry_revision, + }); + profileSettings = response.settings; + defaultProfileDraft = response.settings.default_profile ?? ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + newProfileName = ""; + newProfileDescription = ""; + profileMessage = "Profile source created."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source create failed"; + } finally { + profileSubmitting = false; + } + } + + async function selectProfileSource(sourceId: string) { + profileSubmitting = true; + profileMessage = null; + try { + selectedProfileSource = await fetchProfileSource(workspaceId, sourceId); + profileSourceContent = selectedProfileSource.content; + profileDiagnostics = selectedProfileSource.diagnostics.concat( + selectedProfileSource.source.diagnostics, + selectedProfileSource.profile.diagnostics, + ); + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source load failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitProfileSourceUpdate() { + if (!selectedProfileSource) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await updateProfileSource(workspaceId, selectedProfileSource.source.profile_source_id, { + content: profileSourceContent, + revision: selectedProfileSource.source.revision, + }); + profileSettings = response.settings; + defaultProfileDraft = response.settings.default_profile ?? ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + await selectProfileSource(selectedProfileSource.source.profile_source_id); + profileMessage = "Profile source updated."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source update failed"; + } finally { + profileSubmitting = false; + } + } + + async function submitProfileSourceDelete() { + if (!profileSettings || !selectedProfileSource) return; + profileSubmitting = true; + profileMessage = null; + try { + const response = await deleteProfileSource(workspaceId, selectedProfileSource.source.profile_source_id, { + registry_revision: profileSettings.registry_revision, + source_revision: selectedProfileSource.source.revision, + }); + profileSettings = response.settings; + selectedProfileSource = null; + profileSourceContent = ""; + profileDiagnostics = response.diagnostics.concat(response.settings.diagnostics); + profileMessage = "Profile source deleted."; + } catch (err) { + profileMessage = err instanceof Error ? err.message : "profile source delete failed"; + } finally { + profileSubmitting = false; + } + } + async function submitRemoteRuntime() { submitting = true; mutationMessage = null; @@ -410,8 +611,109 @@ {/if} +
+
+
+

editable

+

Profile Sources

+
+ Backend scoped +
+

+ Workspace profile settings are surfaced as source-qualified selectors and Decodal source summaries. The browser never receives raw host paths, runtime endpoints, tokens, resource handles, archive content, or archive digests. +

+ + {#if profileLoading} +

Loading profile settings…

+ {:else} +
+
{ event.preventDefault(); submitWorkspaceName(); }}> +

Workspace display name

+ +

Workspace id: {workspaceMetadata?.workspace_id ?? workspaceId}

+ +
+ +
{ event.preventDefault(); submitProfileRegistry(); }}> +

Default launch profile

+ + +
+
+ +
+

Discovered profiles

+ + + + + + {#each profileSettings?.profiles ?? [] as profile} + + + + + + + + {/each} + +
SelectorLabelSourceStatusAction
{profile.selector}{profile.label}{profile.source_kind}{profile.diagnostics.length === 0 ? "ok" : `${profile.diagnostics.length} diagnostics`} + {#if profile.profile_source_id} + + {:else} + builtin + {/if} +
+
+ +
+
{ event.preventDefault(); submitNewProfileSource(); }}> +

Create project profile source

+ + + + +
+ + {#if selectedProfileSource} +
{ event.preventDefault(); submitProfileSourceUpdate(); }}> +

Edit {selectedProfileSource.profile.label}

+

Source id: {selectedProfileSource.source.profile_source_id}; display path: {selectedProfileSource.source.display_path}

+ +
+ + +
+
+ {:else} +
+

No profile source selected

+

Open a project source from the discovered profile table to edit it.

+
+ {/if} +
+ + {#if profileMessage} +

{profileMessage}

+ {/if} + {@render DiagnosticsList({ diagnostics: profileDiagnostics })} + {/if} +
+
- {#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections") as section} + {#each SETTINGS_SECTIONS.filter((section) => section.id !== "runtime-connections" && section.id !== "profile-sources") as section}
From e8048e6dc335beaac7a9f1101d7e3881761d4a51 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 21:40:15 +0900 Subject: [PATCH 13/18] ticket: record profile settings implementation evidence --- .yoi/tickets/00001KX0DSMPT/item.md | 2 +- .yoi/tickets/00001KX0DSMPT/thread.md | 33 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index 6832004f..bca691d6 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -2,7 +2,7 @@ title: 'Add Workspace settings API and pages for Workspace/Profile editing' state: 'inprogress' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T12:03:23Z' +updated_at: '2026-07-08T12:40:07Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index b32d6524..c0199a2c 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -182,3 +182,36 @@ Implementation routing recovery note: - Recovery plan: avoid `StopPod` and retry with a fresh minimal Coder task that reads the Ticket records from the worktree instead of embedding the full IntentPacket in the prompt. If scope registry prevents a fresh Pod, fallback is to use the existing Pod with a much smaller instruction or ask for human cleanup/authority。 --- + + + +## Implementation report + +Implementation progress report: + +- Recovery Coder Pod `yoi-coder2-00001KX0DSMPT-profile-settings` completed implementation and committed `3eefd333 feat: add workspace profile settings` on branch `work/00001KX0DSMPT-workspace-profile-settings`. +- Worktree status was clean after commit。 +- Changed files include: + - `crates/workspace-server/src/profile_settings.rs` (new) + - `crates/workspace-server/src/{server.rs,hosts.rs,companion.rs,lib.rs}` + - `web/workspace/src/lib/workspace-settings/{profile-api.ts,profile-types.ts,model.ts}` + - `web/workspace/src/lib/workspace-api/http.ts` + - `web/workspace/src/routes/w/[workspaceId]/settings/+page.svelte` + - child implementation also committed `.yoi/tickets/00001KX0DSMPT/{item.md,thread.md}` changes; Orchestrator will preserve authoritative Ticket thread/item records during integration if conflicts occur。 +- Implementation summary: + - Added scoped Workspace/Profile settings API for workspace metadata and profile registry/source read/write。 + - Added Backend profile settings model with safe source ids/display paths, revision checks, Decodal validation, selector uniqueness checks, path/symlink escape protection, and typed diagnostics。 + - Profile updates rebuild Backend profile discovery/launch options so Worker launch profile candidates and settings profile list use the same discovery result。 + - Extended settings UI with Workspace overview and Profiles list/editor surface using scoped APIs and safe summaries。 + - Browser-facing responses avoid raw host paths, runtime internals, archive digests/content, and resource handles。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cargo test -p yoi-workspace-server`: pass(80 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass +- External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。 + +--- From 1a8824613b5036c90b72221d3e32818899dda338 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 21:49:19 +0900 Subject: [PATCH 14/18] ticket: record profile settings review blockers --- .yoi/tickets/00001KX0DSMPT/item.md | 2 +- .yoi/tickets/00001KX0DSMPT/thread.md | 39 ++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index bca691d6..18c7d446 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -2,7 +2,7 @@ title: 'Add Workspace settings API and pages for Workspace/Profile editing' state: 'inprogress' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T12:40:07Z' +updated_at: '2026-07-08T12:49:09Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index c0199a2c..684e0260 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -215,3 +215,42 @@ Implementation progress report: - External review will be requested via a read-only sibling Reviewer Pod. `StopPod` は使わない。 --- + + + +## Review: request changes + +External review result: request_changes + +Blockers: + +1. Profile mutation validation failures are not returned as typed diagnostics。 + - scoped settings handlers call `profile_settings::*` with `?` directly (`server.rs:881-945`). + - `ApiError::from(Error)` initializes `diagnostics: Vec::new()` (`server.rs:3355-3360`). + - profile errors such as `profile_selector_duplicate`, `profile_source_syntax_invalid`, `profile_source_path_escape`, `profile_registry_revision_conflict` fall through to `BAD_GATEWAY` (`server.rs:3403-3414`) with an empty diagnostics array (`server.rs:3420-3423`). + - Recorded requirement says duplicate selector, invalid registry/schema/Decodal, path/symlink escape, artifact too large, and concurrency conflicts must return typed diagnostics. + +2. Worker launch profile candidates can include invalid project profiles。 + - `load_profile_settings` attaches Decodal/source validation errors from `validate_project_profiles` only to top-level `diagnostics` (`profile_settings.rs:327`, `663-707`). + - each project `WorkspaceProfileSummary` only gets `source_summary` diagnostics (`profile_settings.rs:304-323`). + - `worker_profile_candidates_for_root` includes project profiles when `profile.diagnostics.is_empty()` (`server.rs:2989-2992`). + - `is_profile_candidate` accepts any discovered project profile id (`profile_settings.rs:629-635`). + - Result: registry entry with invalid Decodal source can still appear in launch options and be accepted as a candidate until spawn-time archive resolution fails. + - This violates the intent that profile updates re-run Backend discovery/validation and Worker launch candidates share the same effective validated discovery result. + +3. Registry/default updates can commit references that are not actually Backend-published/valid。 + - `validate_default_profile` accepts any string beginning with `project:` without checking that the project selector exists in submitted registry or discovery result (`profile_settings.rs:946-955`). + - `update_profile_registry` only validates source content if the expected source file already exists (`profile_settings.rs:463-468`) before writing the registry (`profile_settings.rs:470`). + - This permits API writes that persist nonexistent project defaults or missing project source references, contrary to the invariant that writes validate schema/selector/source references before commit and defaults/candidates are Backend-published. + +Validation performed: +- Read Ticket `00001KX0DSMPT` item/thread, IntentPacket, binding invariants, acceptance criteria, implementation report, and reviewer focus。 +- Read-only inspection in implementation worktree `/home/hare/Projects/yoi/.worktree/00001KX0DSMPT-workspace-profile-settings`。 +- Inspected implementation commit scope and relevant backend/frontend files。 +- Relied on Orchestrator-provided validation results; did not modify files or run root/original workspace operations。 + +Non-blocking follow-ups: +- Negative-test coverage is thinner than reviewer focus: duplicate selector, invalid registry schema, symlink escape, artifact size, redaction, concurrent update API response shape, and launch-candidate invalidation need stronger coverage. +- UI implements profile list/editor on single `/settings` page with anchors/forms rather than separate `/settings/profiles/...` pages. This may be acceptable under implementation latitude, but confirm route granularity if it matters. + +--- From 0c2ca1eafbd8e2f8cefe693e469d80747c422200 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 22:06:12 +0900 Subject: [PATCH 15/18] fix: harden profile settings validation --- .yoi/tickets/00001KX0DSMPT/item.md | 2 +- .yoi/tickets/00001KX0DSMPT/thread.md | 23 + crates/workspace-server/src/hosts.rs | 16 +- crates/workspace-server/src/lib.rs | 2 +- .../workspace-server/src/profile_settings.rs | 561 +++++++++++++----- crates/workspace-server/src/server.rs | 289 ++++++++- 6 files changed, 721 insertions(+), 172 deletions(-) diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index aa55a61a..827fa669 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -2,7 +2,7 @@ title: 'Add Workspace settings API and pages for Workspace/Profile editing' state: 'inprogress' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T12:37:56Z' +updated_at: '2026-07-08T13:02:16Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index 63b749d0..db37a790 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -175,4 +175,27 @@ Validation: - nix build .#yoi --no-link +--- + + + +## Implementation report + +Follow-up fixes for external review blockers. + +Summary: +- Profile settings RuntimeOperationFailed responses now include typed Browser diagnostics and map profile validation/conflict/not-found codes to bad-request/conflict/not-found HTTP statuses; response messages are redacted while preserving diagnostic codes. +- Project profile discovery now attaches source/Decodal validation failures to each project profile summary, excludes invalid project profiles from launch candidates, and rejects invalid project selectors during Worker launch candidate validation. +- Registry/default mutations now validate default selector existence, selector/source consistency, missing source files, symlink/path escape, source size, Decodal/ProfileSourceArchive validity, and invalid registry schema before committing registry changes. +- Added API/unit coverage for duplicate selector, invalid Decodal/source schema, stale revision conflicts, too-large source content, symlink escape redaction, invalid candidate exclusion/rejection, and missing/default reference rejection. + +Validation: +- git diff --check +- cargo test -p yoi-workspace-server +- cargo check -p yoi +- cd web/workspace && deno task check && deno task test +- yoi ticket doctor +- nix build .#yoi --no-link + + --- diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index d49d9002..5334be9b 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -1362,11 +1362,11 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { &self.resource_broker, ) }) - .and_then(|bundle| { - self.runtime - .store_config_bundle(bundle) - .map_err(|err| err.to_string()) - }) { + .and_then(|bundle| { + self.runtime + .store_config_bundle(bundle) + .map_err(|err| err.to_string()) + }) { Ok(availability) => availability.reference, Err(error) => { diagnostics.push(diagnostic( @@ -3545,7 +3545,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, - resolved_config_bundle: None, + resolved_config_bundle: None, }, ) .unwrap(); @@ -3648,7 +3648,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, - resolved_config_bundle: None, + resolved_config_bundle: None, }, ) .unwrap(); @@ -3680,7 +3680,7 @@ mod tests { working_directory_request: None, resolved_working_directory_request: None, resolved_working_directory: None, - resolved_config_bundle: None, + resolved_config_bundle: None, }, ) .unwrap(); diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index e77a814e..5c8b11b2 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -8,8 +8,8 @@ pub mod companion; pub mod config; pub mod hosts; pub mod identity; -pub mod profile_settings; pub mod observation; +pub mod profile_settings; pub mod records; pub mod repositories; pub mod resource_broker; diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index 209f6ecf..f1113064 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -230,7 +230,10 @@ pub fn workspace_metadata_settings( diagnostics.push(diagnostic( "workspace_identity_read_failed", DiagnosticSeverity::Error, - format!("Workspace identity could not be read: {}", sanitize_error(&err.to_string())), + format!( + "Workspace identity could not be read: {}", + sanitize_error(&err.to_string()) + ), )); ( fallback_workspace_id.to_string(), @@ -311,6 +314,17 @@ pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> Profi } let source_summary = summarize_source(workspace_root, &source_id, &entry.relative_path); entry_diagnostics.extend(source_summary.diagnostics.clone()); + entry_diagnostics.extend(validate_project_profile_entry(workspace_root, &entry)); + if BUILTIN_PROFILE_SLUGS.contains(&entry.name.as_str()) { + entry_diagnostics.push(diagnostic( + "profile_selector_duplicate", + DiagnosticSeverity::Error, + format!( + "Project profile '{}' conflicts with a builtin selector.", + entry.name + ), + )); + } profiles.push(WorkspaceProfileSummary { profile_id: selector.clone(), selector, @@ -324,7 +338,17 @@ pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> Profi }); sources.push(source_summary); } - diagnostics.extend(validate_project_profiles(workspace_root, ®istry)); + let mut default_diagnostics = validate_registry_default(®istry) + .err() + .map(|err| vec![diagnostic_from_error(&err)]) + .unwrap_or_default(); + diagnostics.extend( + profiles + .iter() + .filter(|profile| profile.source_kind == "project") + .flat_map(|profile| profile.diagnostics.clone()), + ); + diagnostics.append(&mut default_diagnostics); ProfileSettingsResponse { workspace_id: workspace_id.to_string(), registry_revision, @@ -340,7 +364,7 @@ pub fn read_profile_source( workspace_root: &Path, source_id: &str, ) -> Result { - let registry = read_registry(workspace_root).map_err(Error::Config)?; + let registry = read_registry(workspace_root).map_err(profile_registry_error)?; let (name, entry) = entry_for_source_id(®istry, source_id)?; let full = checked_source_path(workspace_root, &entry.relative_path)?; let metadata = source_metadata(&full)?; @@ -380,8 +404,12 @@ pub fn create_profile_source( request: CreateWorkspaceProfileSourceRequest, ) -> Result { let registry_path = registry_path(workspace_root); - ensure_revision(®istry_path, &request.registry_revision, "profile_registry_revision_conflict")?; - let mut registry = read_registry(workspace_root).map_err(Error::Config)?; + ensure_revision( + ®istry_path, + &request.registry_revision, + "profile_registry_revision_conflict", + )?; + let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; let name = validate_profile_name(&request.name)?; if registry.profile.contains_key(&name) || BUILTIN_PROFILE_SLUGS.contains(&name.as_str()) { return Err(Error::RuntimeOperationFailed { @@ -390,7 +418,9 @@ pub fn create_profile_source( message: "Profile selector already exists".to_string(), }); } - let relative_path = PathBuf::from(".yoi").join("profiles").join(format!("{name}.dcdl")); + let relative_path = PathBuf::from(".yoi") + .join("profiles") + .join(format!("{name}.dcdl")); validate_source_content(workspace_root, &name, &relative_path, &request.content)?; let full = checked_source_path(workspace_root, &relative_path)?; if let Some(parent) = full.parent() { @@ -401,9 +431,13 @@ pub fn create_profile_source( name.clone(), ProfileEntryFile::Table(ProfileEntryTable { path: format!("profiles/{name}.dcdl"), - description: request.description.and_then(|value| optional_trim(value.as_str())), + description: request + .description + .and_then(|value| optional_trim(value.as_str())), }), ); + validate_registry_default(®istry)?; + validate_all_project_profiles(workspace_root, ®istry)?; write_registry(workspace_root, ®istry)?; Ok(ProfileSettingsMutationResponse { workspace_id: workspace_id.to_string(), @@ -422,8 +456,11 @@ pub fn update_profile_registry( request: UpdateWorkspaceProfileRegistryRequest, ) -> Result { let path = registry_path(workspace_root); - ensure_revision(&path, &request.registry_revision, "profile_registry_revision_conflict")?; - validate_default_profile(request.default_profile.as_deref())?; + ensure_revision( + &path, + &request.registry_revision, + "profile_registry_revision_conflict", + )?; let mut profile = BTreeMap::new(); let mut seen = BTreeSet::new(); for update in request.profiles { @@ -452,7 +489,9 @@ pub fn update_profile_registry( name.clone(), ProfileEntryFile::Table(ProfileEntryTable { path: format!("profiles/{name}.dcdl"), - description: update.description.and_then(|value| optional_trim(value.as_str())), + description: update + .description + .and_then(|value| optional_trim(value.as_str())), }), ); } @@ -460,13 +499,8 @@ pub fn update_profile_registry( default: request.default_profile, profile, }; - for entry in project_entries(®istry, &mut Vec::new()) { - let full = checked_source_path(workspace_root, &entry.relative_path)?; - if full.exists() { - let content = fs::read_to_string(&full)?; - validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content)?; - } - } + validate_registry_default(®istry)?; + validate_all_project_profiles(workspace_root, ®istry)?; write_registry(workspace_root, ®istry)?; Ok(ProfileSettingsMutationResponse { workspace_id: workspace_id.to_string(), @@ -485,11 +519,16 @@ pub fn update_profile_source( source_id: &str, request: UpdateWorkspaceProfileSourceRequest, ) -> Result { - let registry = read_registry(workspace_root).map_err(Error::Config)?; + let registry = read_registry(workspace_root).map_err(profile_registry_error)?; let (name, entry) = entry_for_source_id(®istry, source_id)?; let full = checked_source_path(workspace_root, &entry.relative_path)?; ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?; - validate_source_content(workspace_root, &name, &entry.relative_path, &request.content)?; + validate_source_content( + workspace_root, + &name, + &entry.relative_path, + &request.content, + )?; fs::write(&full, request.content)?; Ok(ProfileSettingsMutationResponse { workspace_id: workspace_id.to_string(), @@ -509,11 +548,19 @@ pub fn delete_profile_source( request: DeleteWorkspaceProfileSourceRequest, ) -> Result { let registry_path = registry_path(workspace_root); - ensure_revision(®istry_path, &request.registry_revision, "profile_registry_revision_conflict")?; - let mut registry = read_registry(workspace_root).map_err(Error::Config)?; + ensure_revision( + ®istry_path, + &request.registry_revision, + "profile_registry_revision_conflict", + )?; + let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; let (name, entry) = entry_for_source_id(®istry, source_id)?; let full = checked_source_path(workspace_root, &entry.relative_path)?; - ensure_revision(&full, &request.source_revision, "profile_source_revision_conflict")?; + ensure_revision( + &full, + &request.source_revision, + "profile_source_revision_conflict", + )?; registry.profile.remove(&name); if registry.default.as_deref() == Some(project_selector(&name).as_str()) { registry.default = None; @@ -540,7 +587,7 @@ pub fn build_workspace_profile_archive( if !selector.starts_with("project:") { return Ok(None); } - let registry = read_registry(workspace_root).map_err(Error::Config)?; + let registry = read_registry(workspace_root).map_err(profile_registry_error)?; let mut entrypoints = BTreeMap::new(); let mut sources = BTreeMap::new(); for entry in project_entries(®istry, &mut Vec::new()) { @@ -554,10 +601,15 @@ pub fn build_workspace_profile_archive( return Err(Error::RuntimeOperationFailed { runtime_id: "workspace-backend".to_string(), code: "unknown_profile_selector".to_string(), - message: "Selected project profile is not present in the workspace profile registry".to_string(), + message: "Selected project profile is not present in the workspace profile registry" + .to_string(), }); } - if let Some(default) = registry.default.as_deref().filter(|value| value.starts_with("project:")) { + if let Some(default) = registry + .default + .as_deref() + .filter(|value| value.starts_with("project:")) + { if let Some(path) = entrypoints.get(default).cloned() { entrypoints.insert("default".to_string(), path); } @@ -573,15 +625,18 @@ pub fn build_workspace_profile_archive( code: "profile_source_archive_invalid".to_string(), message: err.to_string(), })?; - archive.verify().and_then(|verified| { - verified - .resolve_profile(selector, workspace_root, "workspace-settings-validation") - .map(|_| ()) - }).map_err(|err| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_invalid".to_string(), - message: err.to_string(), - })?; + archive + .verify() + .and_then(|verified| { + verified + .resolve_profile(selector, workspace_root, "workspace-settings-validation") + .map(|_| ()) + }) + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_invalid".to_string(), + message: err.to_string(), + })?; Ok(Some(archive)) } @@ -622,7 +677,7 @@ pub fn project_profile_candidates(workspace_root: &Path) -> Vec bool { fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec { let labels = [ ("builtin:default", "Default", "Bundled default Yoi profile"), - ("builtin:companion", "Companion", "Bundled Companion role profile"), + ( + "builtin:companion", + "Companion", + "Bundled Companion role profile", + ), ("builtin:intake", "Intake", "Bundled Intake role profile"), - ("builtin:orchestrator", "Orchestrator", "Bundled Orchestrator role profile"), + ( + "builtin:orchestrator", + "Orchestrator", + "Bundled Orchestrator role profile", + ), ("builtin:coder", "Coder", "Bundled Coder role profile"), - ("builtin:reviewer", "Reviewer", "Bundled Reviewer role profile"), + ( + "builtin:reviewer", + "Reviewer", + "Bundled Reviewer role profile", + ), ]; labels .into_iter() @@ -659,52 +726,123 @@ fn builtin_profile_summaries(default_profile: Option<&str>) -> Vec Vec { - let mut diagnostics = Vec::new(); - for entry in project_entries(registry, &mut diagnostics) { - let full = match checked_source_path(workspace_root, &entry.relative_path) { - Ok(path) => path, - Err(err) => { - diagnostics.push(diagnostic( - "profile_source_path_escape", - DiagnosticSeverity::Error, - err.to_string(), - )); - continue; - } - }; - match fs::read_to_string(&full) { - Ok(content) => { - if let Err(err) = validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content) { - diagnostics.push(diagnostic( - "profile_source_invalid", - DiagnosticSeverity::Error, - sanitize_error(&err.to_string()), - )); - } - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => diagnostics.push(diagnostic( - "profile_source_missing", - DiagnosticSeverity::Error, - format!("Profile source '{}' is missing.", entry.name), - )), - Err(err) => diagnostics.push(diagnostic( - "profile_source_read_failed", - DiagnosticSeverity::Error, - sanitize_error(&err.to_string()), - )), +fn validate_project_profile_entry( + workspace_root: &Path, + entry: &ProjectProfileEntry, +) -> Vec { + let full = match checked_source_path(workspace_root, &entry.relative_path) { + Ok(path) => path, + Err(err) => return vec![diagnostic_from_error(&err)], + }; + match fs::read_to_string(&full) { + Ok(content) => { + validate_source_content(workspace_root, &entry.name, &entry.relative_path, &content) + .err() + .map(|err| vec![diagnostic_from_error(&err)]) + .unwrap_or_default() } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => vec![diagnostic( + "profile_source_missing", + DiagnosticSeverity::Error, + format!("Profile source '{}' is missing.", entry.name), + )], + Err(err) => vec![diagnostic( + "profile_source_read_failed", + DiagnosticSeverity::Error, + sanitize_error(&err.to_string()), + )], } - if let Some(default) = registry.default.as_deref() { - if let Err(err) = validate_default_profile(Some(default)) { - diagnostics.push(diagnostic( - "profile_default_invalid", - DiagnosticSeverity::Error, - sanitize_error(&err.to_string()), +} + +fn diagnostic_from_error(err: &Error) -> RuntimeDiagnostic { + match err { + Error::RuntimeOperationFailed { code, message, .. } => diagnostic( + code.clone(), + DiagnosticSeverity::Error, + sanitize_error(message), + ), + other => diagnostic( + "profile_settings_failed", + DiagnosticSeverity::Error, + sanitize_error(&other.to_string()), + ), + } +} + +fn has_error(diagnostics: &[RuntimeDiagnostic]) -> bool { + diagnostics + .iter() + .any(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) +} + +fn validate_all_project_profiles( + workspace_root: &Path, + registry: &ProfileRegistryDocument, +) -> Result<()> { + let mut diagnostics = Vec::new(); + let entries = project_entries(registry, &mut diagnostics); + if let Some(diagnostic) = diagnostics + .into_iter() + .find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) + { + return Err(profile_validation_error( + diagnostic.code, + diagnostic.message, + )); + } + for entry in entries { + if BUILTIN_PROFILE_SLUGS.contains(&entry.name.as_str()) { + return Err(profile_validation_error( + "profile_selector_duplicate", + "Project profile conflicts with a builtin selector", + )); + } + if let Some(diagnostic) = validate_project_profile_entry(workspace_root, &entry) + .into_iter() + .find(|diagnostic| diagnostic.severity == DiagnosticSeverity::Error) + { + return Err(profile_validation_error( + diagnostic.code, + diagnostic.message, )); } } - diagnostics + Ok(()) +} + +fn profile_validation_error(code: impl Into, message: impl Into) -> Error { + Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: code.into(), + message: message.into(), + } +} + +fn profile_registry_error(message: String) -> Error { + profile_validation_error("profile_registry_schema_invalid", sanitize_error(&message)) +} + +fn validate_registry_default(registry: &ProfileRegistryDocument) -> Result<()> { + let Some(value) = registry.default.as_deref() else { + return Ok(()); + }; + if BUILTIN_PROFILE_IDS.contains(&value) { + return Ok(()); + } + if let Some(name) = value.strip_prefix("project:") { + let name = validate_profile_name(name)?; + if registry.profile.contains_key(&name) { + return Ok(()); + } + return Err(profile_validation_error( + "profile_default_unknown", + "Default project profile selector is not present in the workspace profile registry", + )); + } + Err(profile_validation_error( + "profile_default_invalid", + "Default profile must be a Backend-published builtin or project selector", + )) } fn validate_source_content( @@ -738,11 +876,13 @@ fn validate_source_content( code: "profile_source_archive_invalid".to_string(), message: err.to_string(), })?; - let verified = archive.verify().map_err(|err| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_archive_invalid".to_string(), - message: err.to_string(), - })?; + let verified = archive + .verify() + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_archive_invalid".to_string(), + message: err.to_string(), + })?; checked_source_path(workspace_root, relative_path)?; verified .resolve_profile(&selector, workspace_root, "workspace-settings-validation") @@ -797,7 +937,9 @@ fn summarize_source( profile_source_id: source_id.to_string(), display_path, kind: "decodal".to_string(), - editable: diagnostics.iter().all(|d| d.severity != DiagnosticSeverity::Error), + editable: diagnostics + .iter() + .all(|d| d.severity != DiagnosticSeverity::Error), revision, size_bytes, diagnostics, @@ -807,9 +949,16 @@ fn summarize_source( fn read_registry(workspace_root: &Path) -> std::result::Result { let path = registry_path(workspace_root); match fs::read_to_string(&path) { - Ok(raw) => toml::from_str(&raw).map_err(|err| format!("invalid profile registry schema: {err}")), - Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(ProfileRegistryDocument::default()), - Err(err) => Err(format!("failed to read profile registry: {}", sanitize_error(&err.to_string()))), + Ok(raw) => { + toml::from_str(&raw).map_err(|err| format!("invalid profile registry schema: {err}")) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + Ok(ProfileRegistryDocument::default()) + } + Err(err) => Err(format!( + "failed to read profile registry: {}", + sanitize_error(&err.to_string()) + )), } } @@ -824,7 +973,10 @@ fn write_registry(workspace_root: &Path, registry: &ProfileRegistryDocument) -> Ok(()) } -fn project_entries(registry: &ProfileRegistryDocument, diagnostics: &mut Vec) -> Vec { +fn project_entries( + registry: &ProfileRegistryDocument, + diagnostics: &mut Vec, +) -> Vec { registry .profile .iter() @@ -839,7 +991,9 @@ fn project_entries(registry: &ProfileRegistryDocument, diagnostics: &mut Vec (path.clone(), None), - ProfileEntryFile::Table(table) => (table.path.clone(), table.description.clone()), + ProfileEntryFile::Table(table) => { + (table.path.clone(), table.description.clone()) + } }; match registry_relative_source_path(&path) { Ok(relative_path) => Some(ProjectProfileEntry { @@ -869,7 +1023,10 @@ fn project_entries(registry: &ProfileRegistryDocument, diagnostics: &mut Vec Result<(String, ProjectProfileEntry)> { +fn entry_for_source_id( + registry: &ProfileRegistryDocument, + source_id: &str, +) -> Result<(String, ProjectProfileEntry)> { let (name, _) = parse_project_source_id(source_id)?; let mut diagnostics = Vec::new(); let entry = project_entries(registry, &mut diagnostics) @@ -899,11 +1056,16 @@ fn registry_relative_source_path(raw: &str) -> std::result::Result std::result::Result<(), String> { if !path.starts_with(PROFILE_SOURCE_ROOT_RELATIVE_PATH) { - return Err("Profile source path must be under the workspace profile source root.".to_string()); + return Err( + "Profile source path must be under the workspace profile source root.".to_string(), + ); } for component in path.components() { if !matches!(component, Component::Normal(_)) { - return Err("Profile source path must not contain absolute, parent, or prefix components.".to_string()); + return Err( + "Profile source path must not contain absolute, parent, or prefix components." + .to_string(), + ); } } if path.extension().and_then(|value| value.to_str()) != Some("dcdl") { @@ -913,10 +1075,12 @@ fn validate_relative_source_path(path: &Path) -> std::result::Result<(), String> } fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result { - validate_relative_source_path(relative_path).map_err(|message| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_path_escape".to_string(), - message, + validate_relative_source_path(relative_path).map_err(|message| { + Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_path_escape".to_string(), + message, + } })?; let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH); fs::create_dir_all(&source_root)?; @@ -927,7 +1091,8 @@ fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result Result) -> Result<()> { - if let Some(value) = value { - if BUILTIN_PROFILE_IDS.contains(&value) || value.starts_with("project:") { - return Ok(()); - } - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_default_invalid".to_string(), - message: "Default profile must be a Backend-published builtin or project selector".to_string(), - }); - } - Ok(()) -} - fn validate_profile_name(value: &str) -> Result { let trimmed = value.trim(); if trimmed.is_empty() @@ -968,7 +1120,8 @@ fn validate_profile_name(value: &str) -> Result { return Err(Error::RuntimeOperationFailed { runtime_id: "workspace-backend".to_string(), code: "profile_selector_invalid".to_string(), - message: "Profile selector must contain only ASCII letters, digits, '-' or '_'".to_string(), + message: "Profile selector must contain only ASCII letters, digits, '-' or '_'" + .to_string(), }); } Ok(trimmed.to_string()) @@ -1006,16 +1159,22 @@ pub fn project_source_id(name: &str) -> String { format!("project:{name}") } -pub fn selector_for_builtin_candidate(id: &str) -> Option { +pub fn selector_for_builtin_candidate( + id: &str, +) -> Option { match id { "runtime_default" => Some(worker_runtime::catalog::ProfileSelector::RuntimeDefault), - "builtin:default" | "builtin:companion" | "builtin:intake" | "builtin:orchestrator" - | "builtin:coder" | "builtin:reviewer" => { - Some(worker_runtime::catalog::ProfileSelector::Builtin(id.to_string())) - } - value if value.starts_with("project:") => { - Some(worker_runtime::catalog::ProfileSelector::Named(value.to_string())) - } + "builtin:default" + | "builtin:companion" + | "builtin:intake" + | "builtin:orchestrator" + | "builtin:coder" + | "builtin:reviewer" => Some(worker_runtime::catalog::ProfileSelector::Builtin( + id.to_string(), + )), + value if value.starts_with("project:") => Some( + worker_runtime::catalog::ProfileSelector::Named(value.to_string()), + ), _ => None, } } @@ -1090,7 +1249,8 @@ fn sanitize_error(value: &str) -> String { value .split_whitespace() .map(|token| { - if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions") { + if token.starts_with('/') || token.contains("/.yoi/") || token.contains(".yoi/sessions") + { "" } else { token @@ -1131,14 +1291,18 @@ mod tests { }, ) .unwrap(); - assert!(created - .settings - .profiles - .iter() - .any(|profile| profile.profile_id == "project:alpha")); - assert!(build_workspace_profile_archive(dir.path(), "project:alpha") - .unwrap() - .is_some()); + assert!( + created + .settings + .profiles + .iter() + .any(|profile| profile.profile_id == "project:alpha") + ); + assert!( + build_workspace_profile_archive(dir.path(), "project:alpha") + .unwrap() + .is_some() + ); } #[test] @@ -1151,10 +1315,12 @@ mod tests { ) .unwrap(); let settings = load_profile_settings("workspace-test", dir.path()); - assert!(settings - .diagnostics - .iter() - .any(|diagnostic| diagnostic.code == "profile_source_path_escape")); + assert!( + settings + .diagnostics + .iter() + .any(|diagnostic| diagnostic.code == "profile_source_path_escape") + ); let err = update_profile_registry( "workspace-test", @@ -1166,7 +1332,10 @@ mod tests { }, ) .unwrap_err(); - assert!(err.to_string().contains("profile_registry_revision_conflict")); + assert!( + err.to_string() + .contains("profile_registry_revision_conflict") + ); } #[test] @@ -1186,6 +1355,124 @@ mod tests { }, ) .unwrap_err(); - assert!(err.to_string().contains("profile_source_syntax_invalid") || err.to_string().contains("profile_source_archive_invalid")); + assert!( + err.to_string().contains("profile_source_syntax_invalid") + || err.to_string().contains("profile_source_archive_invalid") + ); + } + + #[test] + fn invalid_project_profile_is_not_a_launch_candidate() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write( + dir.path().join(".yoi/profiles.toml"), + "[profile.bad]\npath = \"profiles/bad.dcdl\"\n", + ) + .unwrap(); + fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap(); + + let settings = load_profile_settings("workspace-test", dir.path()); + let bad = settings + .profiles + .iter() + .find(|profile| profile.profile_id == "project:bad") + .expect("bad profile summary is still visible for repair"); + assert!( + bad.diagnostics + .iter() + .any(|diagnostic| diagnostic.code.starts_with("profile_source_")) + ); + assert!(project_profile_candidates(dir.path()).is_empty()); + assert!(!is_profile_candidate(dir.path(), "project:bad")); + } + + #[test] + fn registry_update_rejects_missing_source_and_unknown_default() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: revision.clone(), + default_profile: Some("project:missing".to_string()), + profiles: Vec::new(), + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("profile_default_unknown")); + + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: revision, + default_profile: None, + profiles: vec![WorkspaceProfileRegistryEntryUpdate { + name: "missing".to_string(), + description: None, + profile_source_id: None, + }], + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("profile_source_missing")); + assert_eq!( + fs::read_to_string(dir.path().join(".yoi/profiles.toml")).unwrap(), + "" + ); + } + + #[test] + fn profile_source_rejects_too_large_content() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let err = create_profile_source( + "workspace-test", + dir.path(), + CreateWorkspaceProfileSourceRequest { + name: "large".to_string(), + description: None, + content: "x".repeat((MAX_PROFILE_SOURCE_BYTES + 1) as usize), + registry_revision: revision, + }, + ) + .unwrap_err(); + assert!(err.to_string().contains("profile_source_too_large")); + } + + #[cfg(unix)] + #[test] + fn registry_update_rejects_symlink_escape() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let outside = dir.path().join("outside.dcdl"); + fs::write(&outside, valid_decodal("escape")).unwrap(); + std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap(); + let revision = file_revision(&dir.path().join(".yoi/profiles.toml")); + let err = update_profile_registry( + "workspace-test", + dir.path(), + UpdateWorkspaceProfileRegistryRequest { + registry_revision: revision, + default_profile: None, + profiles: vec![WorkspaceProfileRegistryEntryUpdate { + name: "escape".to_string(), + description: None, + profile_source_id: None, + }], + }, + ) + .unwrap_err(); + let rendered = err.to_string(); + assert!(rendered.contains("profile_source_symlink_escape")); + assert!(!rendered.contains(dir.path().to_string_lossy().as_ref())); } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 773886d7..ffa67b8a 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -856,15 +856,18 @@ async fn scoped_update_workspace_settings( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let workspace = crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?; - Ok(Json(crate::profile_settings::WorkspaceMetadataMutationResponse { - workspace, - diagnostics: vec![RuntimeDiagnostic { - code: "workspace_metadata_updated".to_string(), - severity: DiagnosticSeverity::Info, - message: "Workspace display metadata was updated.".to_string(), - }], - })) + let workspace = + crate::profile_settings::update_workspace_metadata(&api.config.workspace_root, request)?; + Ok(Json( + crate::profile_settings::WorkspaceMetadataMutationResponse { + workspace, + diagnostics: vec![RuntimeDiagnostic { + code: "workspace_metadata_updated".to_string(), + severity: DiagnosticSeverity::Info, + message: "Workspace display metadata was updated.".to_string(), + }], + }, + )) } async fn scoped_get_profile_settings( @@ -1769,12 +1772,14 @@ async fn create_workspace_worker( State(api): State, Json(request): Json, ) -> ApiResult> { - let profile_selector = profile_selector_for_candidate_with_root(&api.config.workspace_root, &request.profile).ok_or_else(|| { - settings_bad_request( - "unsupported_worker_profile", - "profile must be selected from Backend-published worker profile candidates", - ) - })?; + let profile_selector = + profile_selector_for_candidate_with_root(&api.config.workspace_root, &request.profile) + .ok_or_else(|| { + settings_bad_request( + "unsupported_worker_profile", + "profile must be selected from Backend-published worker profile candidates", + ) + })?; let resolved_config_bundle = if request.profile.starts_with("project:") { crate::profile_settings::build_workspace_profile_config_bundle( &api.config.workspace_root, @@ -3001,12 +3006,14 @@ fn worker_profile_candidates_for_root(workspace_root: &Path) -> Vec Option { - crate::profile_settings::selector_for_builtin_candidate(profile).filter(|_| { - matches!(profile, "builtin:coder" | "runtime_default") - }) + crate::profile_settings::selector_for_builtin_candidate(profile) + .filter(|_| matches!(profile, "builtin:coder" | "runtime_default")) } -fn profile_selector_for_candidate_with_root(workspace_root: &Path, profile: &str) -> Option { +fn profile_selector_for_candidate_with_root( + workspace_root: &Path, + profile: &str, +) -> Option { if profile_selector_for_candidate(profile).is_some() { profile_selector_for_candidate(profile) } else if crate::profile_settings::is_profile_candidate(workspace_root, profile) { @@ -3354,10 +3361,15 @@ struct ApiError { impl From for ApiError { fn from(error: Error) -> Self { - Self { - error, - diagnostics: Vec::new(), - } + let diagnostics = match &error { + Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic { + code: code.clone(), + severity: DiagnosticSeverity::Error, + message: sanitize_backend_error(message), + }], + _ => Vec::new(), + }; + Self { error, diagnostics } } } @@ -3397,6 +3409,23 @@ impl IntoResponse for ApiError { Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_unsupported" => { StatusCode::NOT_IMPLEMENTED } + Error::RuntimeOperationFailed { code, .. } + if code == "profile_registry_revision_conflict" + || code == "profile_source_revision_conflict" + || code == "workspace_metadata_revision_conflict" => + { + StatusCode::CONFLICT + } + Error::RuntimeOperationFailed { code, .. } + if code == "unknown_profile_source" || code == "unknown_profile_selector" => + { + StatusCode::NOT_FOUND + } + Error::RuntimeOperationFailed { code, .. } + if code == "workspace_display_name_invalid" || code.starts_with("profile_") => + { + StatusCode::BAD_REQUEST + } Error::RuntimeOperationFailed { code, .. } if code.ends_with("_blocked") => { StatusCode::CONFLICT } @@ -3414,12 +3443,18 @@ impl IntoResponse for ApiError { Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY, _ => StatusCode::INTERNAL_SERVER_ERROR, }; + let response_message = match &self.error { + Error::RuntimeOperationFailed { code, message, .. } => { + format!("{code}: {}", sanitize_backend_error(message)) + } + _ => sanitize_backend_error(&self.error.to_string()), + }; ( status, [(CONTENT_TYPE, "application/json")], Json(serde_json::json!({ "error": status.canonical_reason().unwrap_or("error"), - "message": self.error.to_string(), + "message": response_message, "diagnostics": self.diagnostics, })) .to_string(), @@ -3435,7 +3470,7 @@ mod tests { use axum::http::Request; use futures::{SinkExt, StreamExt}; use serde_json::{Value, json}; - use std::sync::Arc; + use std::{fs, sync::Arc}; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message; use tower::ServiceExt; @@ -3492,6 +3527,210 @@ mod tests { assert!(!sanitized.contains("/home/example")); } + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostics_for_duplicate_selector() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "coder", + "content": valid_profile_source("coder"), + "registry_revision": "missing" + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_selector_duplicate"); + } + + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostics_for_invalid_decodal() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "bad", + "content": "not decodal", + "registry_revision": "missing" + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert!( + diagnostic_codes(&response.1) + .iter() + .any(|code| code.starts_with("profile_source_")) + ); + } + + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostic_for_invalid_registry_schema() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "[profile\n").unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "schema", + "content": valid_profile_source("schema"), + "registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")) + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_registry_schema_invalid"); + } + + #[tokio::test] + async fn profile_settings_api_returns_conflict_diagnostic_for_stale_revision() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "alpha", + "content": valid_profile_source("alpha"), + "registry_revision": "stale" + }), + ) + .await; + assert_eq!(response.0, StatusCode::CONFLICT); + assert_diagnostic(&response.1, "profile_registry_revision_conflict"); + } + + #[tokio::test] + async fn profile_settings_api_returns_typed_diagnostic_for_too_large_source() { + let dir = tempfile::tempdir().unwrap(); + let response = profile_settings_request( + dir.path(), + "POST", + "/settings/profiles", + json!({ + "name": "large", + "content": "x".repeat((256 * 1024) + 1), + "registry_revision": "missing" + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_source_too_large"); + } + + #[cfg(unix)] + #[tokio::test] + async fn profile_settings_api_redacts_symlink_escape_response() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write(dir.path().join(".yoi/profiles.toml"), "").unwrap(); + let outside = dir.path().join("outside.dcdl"); + fs::write(&outside, valid_profile_source("escape")).unwrap(); + std::os::unix::fs::symlink(&outside, dir.path().join(".yoi/profiles/escape.dcdl")).unwrap(); + let response = profile_settings_request( + dir.path(), + "PUT", + "/settings/profiles/registry", + json!({ + "registry_revision": test_file_revision(&dir.path().join(".yoi/profiles.toml")), + "default_profile": null, + "profiles": [{ "name": "escape", "profile_source_id": "project:escape" }] + }), + ) + .await; + assert_eq!(response.0, StatusCode::BAD_REQUEST); + assert_diagnostic(&response.1, "profile_source_symlink_escape"); + let rendered = response.1.to_string(); + assert!(!rendered.contains(dir.path().to_string_lossy().as_ref())); + } + + #[tokio::test] + async fn worker_launch_rejects_invalid_project_profile_candidate() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join(".yoi/profiles")).unwrap(); + fs::write( + dir.path().join(".yoi/profiles.toml"), + "[profile.bad]\npath = \"profiles/bad.dcdl\"\n", + ) + .unwrap(); + fs::write(dir.path().join(".yoi/profiles/bad.dcdl"), "not decodal").unwrap(); + assert!( + worker_profile_candidates_for_root(dir.path()) + .iter() + .all(|candidate| candidate.id != "project:bad") + ); + assert!(profile_selector_for_candidate_with_root(dir.path(), "project:bad").is_none()); + } + + fn valid_profile_source(slug: &str) -> String { + format!( + r#"{{ + slug = "{slug}"; + description = "Test"; + scope = "workspace_read"; + }}"# + ) + } + + fn test_file_revision(path: &Path) -> String { + let Ok(metadata) = fs::metadata(path) else { + return "missing".to_string(); + }; + let modified = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()) + .unwrap_or_default(); + format!("rev:{modified}:{}", metadata.len()) + } + + async fn profile_settings_request( + workspace_root: &Path, + method: &str, + path: &str, + body: Value, + ) -> (StatusCode, Value) { + let app = test_app(workspace_root.to_path_buf()).await; + let request = Request::builder() + .method(method) + .uri(format!("/api/w/{TEST_WORKSPACE_ID}{path}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap(); + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 1024 * 1024).await.unwrap(); + let json = serde_json::from_slice::(&bytes).unwrap(); + (status, json) + } + + fn diagnostic_codes(response: &Value) -> Vec { + response["diagnostics"] + .as_array() + .expect("diagnostics array") + .iter() + .map(|diagnostic| diagnostic["code"].as_str().unwrap().to_string()) + .collect() + } + + fn assert_diagnostic(response: &Value, code: &str) { + let codes = diagnostic_codes(response); + assert!( + !codes.is_empty(), + "diagnostics must not be empty: {response}" + ); + assert!( + codes.iter().any(|actual| actual == code), + "missing {code}: {codes:?}" + ); + } + #[derive(Default)] struct DeterministicExecutionBackend { contexts: std::sync::Mutex< From bfba61cee6d5ea4afd9b421c1285c7ba15828c0d Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 22:07:45 +0900 Subject: [PATCH 16/18] ticket: record profile settings review fixes --- .yoi/tickets/00001KX0DSMPT/item.md | 2 +- .yoi/tickets/00001KX0DSMPT/thread.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index 18c7d446..6c67a27c 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -2,7 +2,7 @@ title: 'Add Workspace settings API and pages for Workspace/Profile editing' state: 'inprogress' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T12:49:09Z' +updated_at: '2026-07-08T13:07:39Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index 684e0260..e5e758a1 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -254,3 +254,29 @@ Non-blocking follow-ups: - UI implements profile list/editor on single `/settings` page with anchors/forms rather than separate `/settings/profiles/...` pages. This may be acceptable under implementation latitude, but confirm route granularity if it matters. --- + + + +## Implementation report + +Review-fix implementation report: + +- Recovery Coder Pod completed requested changes and committed follow-up `0c2ca1ea fix: harden profile settings validation` on branch `work/00001KX0DSMPT-workspace-profile-settings`. +- Worktree status was clean after commit。 +- Fix summary: + - Profile settings validation errors now map to typed Browser diagnostics with non-empty `diagnostics` arrays and appropriate HTTP statuses(revision conflict 409、invalid/duplicate/path/schema/source/profile errors 400、unknown source/selector 404)。 + - Project profile discovery attaches source/Decodal validation failures to the relevant `WorkspaceProfileSummary`。 + - Invalid project profiles are excluded from Worker launch candidates and rejected by candidate validation。 + - Registry/default writes validate default selector existence, selector/source consistency, missing source files, path/symlink escape, source size, Decodal/ProfileSourceArchive validity, and invalid registry schema before commit。 + - Added/strengthened tests for duplicate selector, invalid Decodal/schema, stale revision conflict, too-large source, symlink escape redaction, invalid launch candidate rejection, and missing/default reference rejection。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cargo test -p yoi-workspace-server`: pass(91 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass +- Requesting follow-up external review against `3eefd333..0c2ca1ea` and full combined implementation。 + +--- From 137234fae0f7f191fe7834122888b23933bc7b6c Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 22:19:04 +0900 Subject: [PATCH 17/18] ticket: approve profile settings implementation --- .yoi/tickets/00001KX0DSMPT/item.md | 2 +- .yoi/tickets/00001KX0DSMPT/thread.md | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index 6c67a27c..756e8095 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -2,7 +2,7 @@ title: 'Add Workspace settings API and pages for Workspace/Profile editing' state: 'inprogress' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T13:07:39Z' +updated_at: '2026-07-08T13:18:55Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index e5e758a1..09473cc8 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -280,3 +280,27 @@ Review-fix implementation report: - Requesting follow-up external review against `3eefd333..0c2ca1ea` and full combined implementation。 --- + + + +## Review: approve + +External follow-up review result: approve + +Blockers: none. + +Validation performed read-only in implementation worktree. Reviewer inspected Ticket records, `0c2ca1ea`, fix diff `3eefd333..0c2ca1ea`, and relevant server/profile/UI code. Reviewer did not rerun heavy validation and relied on Orchestrator-reported passing validation. + +Findings: +- Prior blocker 1 resolved: profile mutation failures now return typed Browser diagnostics with populated diagnostics rather than generic empty diagnostics. Server-side handlers/tests cover invalid source mutations and missing-source registry mutations with non-empty diagnostic arrays and bad-request status。 +- Prior blocker 2 resolved: invalid/missing/Decodal-invalid project profile sources are surfaced on relevant profile summaries, invalid project profiles are excluded from Worker launch candidates, and candidate validation rejects invalid project profile ids with regression coverage。 +- Prior blocker 3 resolved: registry/default update paths validate selector/default existence, selector/source-id correspondence, missing source files, source path safety including symlink escape rejection, source size, Decodal/ProfileSourceArchive validity, and invalid registry schema before accepting mutations。 +- Browser-facing profile/settings summaries expose bounded ids, labels, relative display paths, revision/configured booleans, and diagnostics; no host absolute paths, socket/session paths, runtime-local store paths, runtime endpoints/tokens, backend-private paths, archive digests, raw resource handles, or secret values were found in inspected API/UI surfaces。 +- Profile detail exposes editable source content as intended settings-editor payload, not resolved archive/resource bundle。 +- Decodal/ProfileSourceArchive validation remains behind server-side checked source path/archive boundary; Runtime filesystem discovery was not reintroduced for profile validation or launch。 +- Single-page settings UI route structure is acceptable under implementation latitude and not a blocker。 + +Non-blocking follow-up: +- If merge target accumulated additional `.yoi/tickets/00001KX0DSMPT/thread.md` edits, resolve Ticket-record conflicts normally during merge; this does not affect product/code correctness. + +--- From 5c862ca12ac04ab9beb6bbe7964e0c44aab48716 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Jul 2026 22:20:36 +0900 Subject: [PATCH 18/18] ticket: close workspace profile settings work --- .yoi/tickets/00001KX0DSMPT/item.md | 4 +- .yoi/tickets/00001KX0DSMPT/resolution.md | 33 +++++++++++ .yoi/tickets/00001KX0DSMPT/thread.md | 75 ++++++++++++++++++++++++ 3 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KX0DSMPT/resolution.md diff --git a/.yoi/tickets/00001KX0DSMPT/item.md b/.yoi/tickets/00001KX0DSMPT/item.md index 756e8095..40b5547a 100644 --- a/.yoi/tickets/00001KX0DSMPT/item.md +++ b/.yoi/tickets/00001KX0DSMPT/item.md @@ -1,8 +1,8 @@ --- title: 'Add Workspace settings API and pages for Workspace/Profile editing' -state: 'inprogress' +state: 'closed' created_at: '2026-07-08T08:34:01Z' -updated_at: '2026-07-08T13:18:55Z' +updated_at: '2026-07-08T13:20:27Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T10:44:13Z' diff --git a/.yoi/tickets/00001KX0DSMPT/resolution.md b/.yoi/tickets/00001KX0DSMPT/resolution.md new file mode 100644 index 00000000..9bdc534e --- /dev/null +++ b/.yoi/tickets/00001KX0DSMPT/resolution.md @@ -0,0 +1,33 @@ +完了。 + +実装内容: +- Workspace/Profile settings scoped API を追加した。 +- Workspace metadata read/update と safe identity/source summaries を扱う Settings API を追加した。 +- Profile registry/source read/update API を追加し、create/update/delete と revision/etag-style optimistic checks を実装した。 +- Backend profile settings model に safe source ids/display paths、Decodal/ProfileSourceArchive validation、selector uniqueness、path/symlink escape protection、source size checks、typed diagnostics を追加した。 +- Profile updates 後に Backend profile discovery / validation / launch options を再構築し、Worker launch profile candidates と Settings profile list が同じ discovery result を使うようにした。 +- invalid project profile は relevant profile summary に diagnostics を付与し、Worker launch candidates から除外し、candidate validation でも reject するようにした。 +- Browser-facing responses/UI から host absolute paths、secret values、Runtime endpoints/tokens、socket/session paths、runtime-local store paths、archive content/digest、raw resource handles、backend-private paths を出さないようにした。 +- Settings UI に Workspace overview と Profile list/source editor surface を追加した。 +- Focused backend/web tests を追加/強化した。 + +主な commit / merge: +- implementation: `3eefd333 feat: add workspace profile settings` +- review fix: `0c2ca1ea fix: harden profile settings validation` +- merge into orchestration: `0bf3638c merge: workspace profile settings` + +Review: +- 初回 external review は request_changes。 +- follow-up external review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cargo test -p yoi-workspace-server`: pass(91 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(17 tests) +- `yoi ticket doctor`: ok +- `nix build .#yoi --no-link`: pass + +補足: +- implementation branch merge 時に Ticket record conflict が発生したため、Orchestrator 側の Ticket item/thread を保持して解決した。product/code changes は merge 済み。 \ No newline at end of file diff --git a/.yoi/tickets/00001KX0DSMPT/thread.md b/.yoi/tickets/00001KX0DSMPT/thread.md index 09473cc8..282aef3c 100644 --- a/.yoi/tickets/00001KX0DSMPT/thread.md +++ b/.yoi/tickets/00001KX0DSMPT/thread.md @@ -304,3 +304,78 @@ Non-blocking follow-up: - If merge target accumulated additional `.yoi/tickets/00001KX0DSMPT/thread.md` edits, resolve Ticket-record conflicts normally during merge; this does not affect product/code correctness. --- + + + +## State changed + +Merge/validation acceptance: + +- Follow-up external review approved the implementation after blocker fixes。 +- Implementation branch `work/00001KX0DSMPT-workspace-profile-settings` was merged into the orchestration branch with merge commit `0bf3638c merge: workspace profile settings`。 +- Ticket-record conflicts during merge were resolved by preserving Orchestrator-side Ticket item/thread records; product/code changes were merged。 +- Final validation in Orchestrator worktree passed: + - `git diff --check`: pass + - `cargo test -p yoi-workspace-server`: pass(91 lib tests + 2 main tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(17 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass + +Implementation commits: +- `3eefd333 feat: add workspace profile settings` +- `0c2ca1ea fix: harden profile settings validation` + +No merge-blocking issues remain. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +完了。 + +実装内容: +- Workspace/Profile settings scoped API を追加した。 +- Workspace metadata read/update と safe identity/source summaries を扱う Settings API を追加した。 +- Profile registry/source read/update API を追加し、create/update/delete と revision/etag-style optimistic checks を実装した。 +- Backend profile settings model に safe source ids/display paths、Decodal/ProfileSourceArchive validation、selector uniqueness、path/symlink escape protection、source size checks、typed diagnostics を追加した。 +- Profile updates 後に Backend profile discovery / validation / launch options を再構築し、Worker launch profile candidates と Settings profile list が同じ discovery result を使うようにした。 +- invalid project profile は relevant profile summary に diagnostics を付与し、Worker launch candidates から除外し、candidate validation でも reject するようにした。 +- Browser-facing responses/UI から host absolute paths、secret values、Runtime endpoints/tokens、socket/session paths、runtime-local store paths、archive content/digest、raw resource handles、backend-private paths を出さないようにした。 +- Settings UI に Workspace overview と Profile list/source editor surface を追加した。 +- Focused backend/web tests を追加/強化した。 + +主な commit / merge: +- implementation: `3eefd333 feat: add workspace profile settings` +- review fix: `0c2ca1ea fix: harden profile settings validation` +- merge into orchestration: `0bf3638c merge: workspace profile settings` + +Review: +- 初回 external review は request_changes。 +- follow-up external review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cargo test -p yoi-workspace-server`: pass(91 lib tests + 2 main tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(17 tests) +- `yoi ticket doctor`: ok +- `nix build .#yoi --no-link`: pass + +補足: +- implementation branch merge 時に Ticket record conflict が発生したため、Orchestrator 側の Ticket item/thread を保持して解決した。product/code changes は merge 済み。 + +---