From 7da14f1a8d8cbe4df909de206a93808c36fabe56 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Jul 2026 16:58:08 +0900 Subject: [PATCH 01/12] ticket: accept profile source tree work --- .../artifacts/orchestration-plan.jsonl | 1 + .yoi/tickets/00001KX1JNJ2Y/item.md | 4 +- .yoi/tickets/00001KX1JNJ2Y/thread.md | 93 +++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KX1JNJ2Y/artifacts/orchestration-plan.jsonl diff --git a/.yoi/tickets/00001KX1JNJ2Y/artifacts/orchestration-plan.jsonl b/.yoi/tickets/00001KX1JNJ2Y/artifacts/orchestration-plan.jsonl new file mode 100644 index 00000000..83bd1e43 --- /dev/null +++ b/.yoi/tickets/00001KX1JNJ2Y/artifacts/orchestration-plan.jsonl @@ -0,0 +1 @@ +{"id":"orch-plan-20260709-075750-1","ticket_id":"00001KX1JNJ2Y","kind":"accepted_plan","accepted_plan":{"summary":"`00001KWZ5KERY` Decodal/ProfileSourceArchive と `00001KX0DSMPT` Workspace/Profile settings が closed/merged 済み。ProfileSourceTree virtual filesystem と Decodal editor follow-up を実装開始する。","branch":"work/00001KX1JNJ2Y-profile-source-tree","worktree":"/home/hare/Projects/yoi/.worktree/00001KX1JNJ2Y-profile-source-tree","role_plan":"単一 sibling Coder Pod に implementation worktree を委譲し、ProfileSourceTree virtual FS model/resolver、ProfileSourceArchive tree-based builder、Runtime archive virtual-path loader validation、scoped file-tree settings API、Decodal CodeMirror editor UI、tests/Nix dependency update を実装する。完了後、別 sibling Reviewer Pod で Ticket IntentPacket / path-redaction / import-resolution authority boundary / acceptance criteria に照らして read-only review する。"},"author":"orchestrator","at":"2026-07-09T07:57:50Z"} diff --git a/.yoi/tickets/00001KX1JNJ2Y/item.md b/.yoi/tickets/00001KX1JNJ2Y/item.md index c5341fd0..b6b65dbb 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/item.md +++ b/.yoi/tickets/00001KX1JNJ2Y/item.md @@ -1,8 +1,8 @@ --- title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' -state: 'queued' +state: 'inprogress' created_at: '2026-07-08T19:18:25Z' -updated_at: '2026-07-09T07:56:07Z' +updated_at: '2026-07-09T07:57:59Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-09T07:56:07Z' diff --git a/.yoi/tickets/00001KX1JNJ2Y/thread.md b/.yoi/tickets/00001KX1JNJ2Y/thread.md index 9a06a342..1d662966 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/thread.md +++ b/.yoi/tickets/00001KX1JNJ2Y/thread.md @@ -30,4 +30,97 @@ Marked ready by `yoi ticket state`. Ticket を `workspace-panel` が queued にしました。 +--- + + + +## Decision + +Routing decision: implementation_ready + +Reason: +- Declared dependency `00001KWZ5KERY` is closed, so Decodal ProfileSourceArchive / archive verify / Runtime FSなし resolution の基礎は available。 +- Related follow-up `00001KX0DSMPT` Workspace/Profile settings API/pages is also closed, so settings/profile editing surface exists and this Ticket can refine it to ProfileSourceTree virtual filesystem + Decodal editor。 +- Ticket body gives concrete model/API/UI/editor/dependency/validation requirements and clear non-goals。 +- typed relation blocker is resolved; OrchestrationPlan record は 0 件。inprogress Ticket も 0 件。 +- queued notification is human authorized routing/start。 + +Evidence checked: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KX1JNJ2Y)`: outgoing `depends_on 00001KWZ5KERY`; target is closed。 +- `TicketOrchestrationPlanQuery(00001KX1JNJ2Y)`: 0 件。 +- `TicketList`: queued はこの Ticket 1件、inprogress は 0 件。 +- Orchestrator worktree git status: clean on `orchestration`。 +- Relevant prior work: Decodal/ProfileSourceArchive closed, Workspace/Profile settings closed。 + +IntentPacket: + +Intent: +- Add Backend-owned `ProfileSourceTree` virtual filesystem for Decodal profile sources, connect ProfileSourceArchive import closure building to that tree, strengthen Runtime archive import validation, and add Workspace settings Profile source tree editor using `decodal-codemirror@0.1.2` / CodeMirror 6。 + +Binding decisions / invariants: +- Browser/editor works with virtual relative paths and safe source tree/file ids, never host absolute paths or raw filesystem authority。 +- Backend validation is authority; Browser editor diagnostics are only assistance。 +- Runtime reads only ProfileSourceArchive manifest / virtual paths, not Workspace filesystem。 +- `ProfileSourceArchive` is a snapshot of source tree revision + selected root + import closure。 +- Browser-facing API must not expose host absolute path, symlink target path, Runtime endpoint/socket/session path, secret values, WorkingDirectory root path, raw resource handles, archive digest/content, or backend-private paths。 +- `decodal-codemirror@0.1.2` / CodeMirror 6 dependency is in scope; formatter and Browser-side Decodal materialization as authority are out of scope。 + +Requirements / acceptance criteria: +- Implement typed `ProfileSourceTree` model with tree id, revision/generation, virtual root metadata, files, provenance, diagnostics, and safe summaries。 +- Implement virtual import resolver for relative imports and adopted namespace imports, with normalization and typed diagnostics for root escape, absolute path, URL/unsupported scheme, extension/content-type, depth/count/bytes limits。 +- Build `ProfileSourceArchive` from selected root virtual path and source tree import closure, including virtual path / source key / digest / size / content type / import map in manifest。 +- Runtime `ArchiveSourceLoader` validates manifest-contained virtual paths only and rejects manifest-outside import, absolute path, `..`, unsupported namespace。 +- Add scoped Profile settings file-tree API: list trees, list files, read/write/create/delete file, validate tree/file; rename/move optional。 +- Add Settings UI profile source tree list and file tree editor routes/pages using Decodal CodeMirror extension and Backend save/create/delete/validate APIs。 +- Focused backend/runtime/web tests cover path normalization, import closure, archive manifest validation, Runtime import resolution, Browser redaction, file tree API, and editor route smoke。 + +Implementation latitude: +- Exact endpoint names, source tree storage format, namespace enum details, UI route/component split, and editor model can follow existing workspace-server/Svelte style。 +- v0 may keep source tree storage minimal and project/workspace-backed, provided Browser contract remains virtual-path based and Backend validation remains authority。 + +Escalate if: +- Browser-facing raw host path authority, Runtime workspace filesystem discovery, Browser-side Decodal materialization as authority, formatter integration, plugin/MCP/sandbox artifact sync, or Profile source RDB-normalized editor becomes required。 + +Validation: +- `cargo test -p yoi-workspace-server` +- `cargo test -p worker-runtime --features ws-server,fs-store` +- `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: +- Backend profile settings/source model: `crates/workspace-server/src/profile_settings.rs`, `server.rs`, `hosts.rs`。 +- Decodal/Profile archive: `crates/worker-runtime/src/profile_archive.rs`, `config_bundle.rs`。 +- Frontend settings/profile editor: `web/workspace/src/routes/w/[workspaceId]/settings/**`, `web/workspace/src/lib/workspace-settings/**`。 + +Critical risks / reviewer focus: +- virtual paths and tar paths must not allow absolute path / `..` / symlink/path escape。 +- Browser must never receive host absolute path, secret, runtime endpoint, archive digest/content, or raw handle。 +- Runtime SourceLoader must be archive-manifest/import-map only, no FS/backend fallback。 +- source tree validation/write must be revision-aware and fail closed with typed diagnostics。 +- new editor dependency and Nix/lock updates must be correct。 + +--- + + + +## State changed + +Queued acceptance recorded after Workspace Dashboard Queue notification authorized Orchestrator routing. + +Checked context: +- Ticket body / thread / artifacts。 +- `TicketRelationQuery(00001KX1JNJ2Y)`: depends_on `00001KWZ5KERY`; target is closed。 +- `TicketOrchestrationPlanQuery(00001KX1JNJ2Y)`: prior record 0 件。今回 accepted_plan を記録済み。 +- Orchestrator worktree git status: clean on `orchestration`。 +- queued Ticket はこの Ticket 1件のみ。inprogress は 0 件。 + +Acceptance basis: +- concrete missing decision / information は残っていない。 +- Ticket scope is ProfileSourceTree virtual filesystem + Decodal editor follow-up on top of closed Decodal/ProfileSourceArchive and Workspace/Profile settings work。 +- side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 + --- From ce2ccce98e5df0e1d830ae1b775077a802bf5347 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Jul 2026 16:59:00 +0900 Subject: [PATCH 02/12] ticket: route profile source tree coder --- .yoi/tickets/00001KX1JNJ2Y/item.md | 2 +- .yoi/tickets/00001KX1JNJ2Y/thread.md | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX1JNJ2Y/item.md b/.yoi/tickets/00001KX1JNJ2Y/item.md index b6b65dbb..601c9805 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/item.md +++ b/.yoi/tickets/00001KX1JNJ2Y/item.md @@ -2,7 +2,7 @@ title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' state: 'inprogress' created_at: '2026-07-08T19:18:25Z' -updated_at: '2026-07-09T07:57:59Z' +updated_at: '2026-07-09T07:58:54Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-09T07:56:07Z' diff --git a/.yoi/tickets/00001KX1JNJ2Y/thread.md b/.yoi/tickets/00001KX1JNJ2Y/thread.md index 1d662966..340663b7 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/thread.md +++ b/.yoi/tickets/00001KX1JNJ2Y/thread.md @@ -124,3 +124,21 @@ 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/00001KX1JNJ2Y-profile-source-tree` + - Branch: `work/00001KX1JNJ2Y-profile-source-tree` +- Worktree には tracked `.yoi` project records が見えている。 +- `.yoi/memory` / `.yoi/runtime` / `.yoi/log` / `.yoi/logs` / `.yoi/local` / `.yoi/*.lock` / `.yoi/*secret*` は作成直後に存在しないことを確認した。 +- Sibling Coder Pod `yoi-coder-00001KX1JNJ2Y-source-tree` を起動した。 +- Scope policy: runtime workspace root は read-only、implementation worktree は write 可。Coder task では root/original workspace での read/write/validation/git operation を禁止し、この implementation worktree だけを作業対象に指定した。 +- 初回プロンプト肥大化を避けるため、Coder には短い task と Ticket record 読み取り指示を渡した。 +- 完了後は diff / validation evidence を Orchestrator が確認し、別 sibling Reviewer Pod に read-only review を依頼する。`StopPod` は既知の不具合があるため使用しない。 + +--- From 81abfa630ab834c1a791ee21aba7d7e7883fe622 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Jul 2026 17:22:57 +0900 Subject: [PATCH 03/12] feat: add profile source tree editor --- .yoi/tickets/00001KX1JNJ2Y/item.md | 2 +- .yoi/tickets/00001KX1JNJ2Y/thread.md | 25 + crates/worker-runtime/src/profile_archive.rs | 256 +++++++- .../workspace-server/src/profile_settings.rs | 554 +++++++++++++++--- crates/workspace-server/src/server.rs | 67 ++- web/workspace/deno.json | 2 + web/workspace/deno.lock | 31 + .../DecodalSourceEditor.svelte | 71 +++ .../src/lib/workspace-settings/profile-api.ts | 43 ++ .../lib/workspace-settings/profile-types.ts | 36 ++ .../settings/profiles/+page.svelte | 140 +++-- 11 files changed, 1106 insertions(+), 121 deletions(-) create mode 100644 web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte diff --git a/.yoi/tickets/00001KX1JNJ2Y/item.md b/.yoi/tickets/00001KX1JNJ2Y/item.md index b6b65dbb..37f47bf6 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/item.md +++ b/.yoi/tickets/00001KX1JNJ2Y/item.md @@ -2,7 +2,7 @@ title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' state: 'inprogress' created_at: '2026-07-08T19:18:25Z' -updated_at: '2026-07-09T07:57:59Z' +updated_at: '2026-07-09T08:22:18Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-09T07:56:07Z' diff --git a/.yoi/tickets/00001KX1JNJ2Y/thread.md b/.yoi/tickets/00001KX1JNJ2Y/thread.md index 1d662966..2fffc4de 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/thread.md +++ b/.yoi/tickets/00001KX1JNJ2Y/thread.md @@ -123,4 +123,29 @@ Acceptance basis: - Ticket scope is ProfileSourceTree virtual filesystem + Decodal editor follow-up on top of closed Decodal/ProfileSourceArchive and Workspace/Profile settings work。 - side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 +--- + + + +## Implementation report + +Implemented ProfileSourceTree virtual source-tree support for project Decodal profiles. + +Summary: +- Added Runtime archive manifest validation for virtual source metadata, duplicate/undeclared tar entries, scoped import-map keys, namespace/path safety, and current-source relative import resolution without filesystem reads. +- Added Backend-owned project `ProfileSourceTree` API over virtual `profiles/...` paths, revision-aware read/write/delete file operations, path validation, tree scanning, import closure/archive building from tree contents, and project-profile config bundle generation from archives. +- Extended workspace server routes and browser API/types for scoped profile source tree access without exposing host absolute paths or resource/archive internals. +- Added a Profiles settings Decodal CodeMirror editor backed by virtual paths/revision tokens only. +- Added worker-runtime tests for scoped virtual imports and import-map mismatch rejection. + +Validation: +- `git diff --check` +- `cargo test -p yoi-workspace-server` +- `cargo test -p worker-runtime --features ws-server,fs-store` +- `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/worker-runtime/src/profile_archive.rs b/crates/worker-runtime/src/profile_archive.rs index b759c9d6..a610fe20 100644 --- a/crates/worker-runtime/src/profile_archive.rs +++ b/crates/worker-runtime/src/profile_archive.rs @@ -2,7 +2,7 @@ use decodal::{Engine, LoadedSource, SourceLoader}; use manifest::{ProfileSource, WorkerManifest, resolve_profile_artifact_value}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::io::{Cursor, Read, Write}; use std::path::{Component, Path, PathBuf}; use tar::{Archive, Builder, Header}; @@ -27,6 +27,10 @@ pub enum ProfileArchiveError { MissingManifest, #[error("profile source archive missing source {0}")] MissingSource(String), + #[error("profile source archive contains duplicate source {0}")] + DuplicateSource(String), + #[error("profile source archive contains source not declared in manifest {0}")] + UndeclaredSource(String), #[error( "profile source archive source digest mismatch for {path}: expected {expected}, got {actual}" )] @@ -47,6 +51,14 @@ pub enum ProfileArchiveError { MissingEntrypoint(String), #[error("profile source archive import is not declared in manifest import map: {0}")] ImportNotDeclared(String), + #[error( + "profile source archive import map mismatch for {specifier}: expected {expected}, got {actual}" + )] + ImportMapMismatch { + specifier: String, + expected: String, + actual: String, + }, #[error("failed to read profile source archive: {0}")] Io(String), #[error("failed to parse profile source archive manifest: {0}")] @@ -94,7 +106,11 @@ pub struct ProfileSourceArchiveManifest { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ProfileSourceArchiveSource { pub path: String, + #[serde(default)] + pub source_key: String, pub kind: String, + #[serde(default = "default_decodal_content_type")] + pub content_type: String, pub digest: String, pub size_bytes: u64, } @@ -128,7 +144,9 @@ impl ProfileSourceArchive { } source_meta.push(ProfileSourceArchiveSource { path: path.clone(), + source_key: path.clone(), kind: "decodal".to_string(), + content_type: default_decodal_content_type(), digest: sha256_hex(source.as_bytes()), size_bytes: size, }); @@ -238,7 +256,9 @@ impl VerifiedProfileSourceArchive { } else { let source = String::from_utf8(data) .map_err(|err| ProfileArchiveError::Io(err.to_string()))?; - entries.insert(path, source); + if entries.insert(path.clone(), source).is_some() { + return Err(ProfileArchiveError::DuplicateSource(path)); + } } if entries.len() > MAX_SOURCES { return Err(ProfileArchiveError::LimitExceeded("source count")); @@ -258,8 +278,25 @@ impl VerifiedProfileSourceArchive { }); } let mut total = 0u64; + let mut manifest_paths = BTreeSet::new(); for source in &manifest.sources { validate_archive_path(&source.path)?; + if !source.source_key.is_empty() && source.source_key != source.path { + return Err(ProfileArchiveError::ImportMapMismatch { + specifier: source.path.clone(), + expected: source.path.clone(), + actual: source.source_key.clone(), + }); + } + if source.content_type != default_decodal_content_type() { + return Err(ProfileArchiveError::UnsupportedSource { + path: source.path.clone(), + kind: source.content_type.clone(), + }); + } + if !manifest_paths.insert(source.path.clone()) { + return Err(ProfileArchiveError::DuplicateSource(source.path.clone())); + } if source.kind != "decodal" { return Err(ProfileArchiveError::UnsupportedSource { path: source.path.clone(), @@ -285,6 +322,11 @@ impl VerifiedProfileSourceArchive { } total += size; } + for entry_path in entries.keys() { + if !manifest_paths.contains(entry_path) { + return Err(ProfileArchiveError::UndeclaredSource(entry_path.clone())); + } + } if total != reference.source_graph.total_source_bytes || manifest.sources.len() != reference.source_graph.source_count { @@ -298,10 +340,13 @@ impl VerifiedProfileSourceArchive { .chain(manifest.imports.values()) { validate_archive_path(path)?; - if !entries.contains_key(path) { + if !manifest_paths.contains(path) || !entries.contains_key(path) { return Err(ProfileArchiveError::MissingSource(path.clone())); } } + for (key, path) in &manifest.imports { + validate_archive_import_map_entry(key, path, &manifest_paths)?; + } Ok(Self { reference, manifest, @@ -313,6 +358,10 @@ impl VerifiedProfileSourceArchive { &self.reference } + pub fn manifest(&self) -> &ProfileSourceArchiveManifest { + &self.manifest + } + pub fn resolve_profile( &self, selector: &str, @@ -378,22 +427,26 @@ impl<'a> ArchiveSourceLoader<'a> { impl SourceLoader for ArchiveSourceLoader<'_> { fn load( &mut self, - _current_key: Option<&str>, + current_key: Option<&str>, specifier: &str, ) -> decodal::Result { - let path = self - .archive - .manifest - .imports - .get(specifier) - .ok_or_else(|| { - decodal::Diagnostic::new( - decodal::DiagnosticKind::Import, - decodal::Span::default(), - format!("archive import not declared: {specifier}"), - ) - })?; - let source = self.archive.sources.get(path).ok_or_else(|| { + let path = + archive_import_map_lookup(&self.archive.manifest.imports, current_key, specifier) + .map_err(import_diagnostic)?; + if let Some(current_key) = current_key { + match resolve_archive_import_path(current_key, specifier) { + Ok(expected) if expected == path => {} + Ok(expected) => { + return Err(import_diagnostic(ProfileArchiveError::ImportMapMismatch { + specifier: format!("{current_key}\0{specifier}"), + expected, + actual: path.clone(), + })); + } + Err(err) => return Err(import_diagnostic(err)), + } + } + let source = self.archive.sources.get(&path).ok_or_else(|| { decodal::Diagnostic::new( decodal::DiagnosticKind::Import, decodal::Span::default(), @@ -408,6 +461,123 @@ impl SourceLoader for ArchiveSourceLoader<'_> { } } +fn default_decodal_content_type() -> String { + "text/x-decodal".to_string() +} + +fn import_diagnostic(err: ProfileArchiveError) -> decodal::Diagnostic { + decodal::Diagnostic::new( + decodal::DiagnosticKind::Import, + decodal::Span::default(), + err.to_string(), + ) +} + +fn archive_import_map_lookup( + imports: &BTreeMap, + current_key: Option<&str>, + specifier: &str, +) -> Result { + if let Some(current_key) = current_key { + let scoped = format!("{current_key}\0{specifier}"); + if let Some(path) = imports.get(&scoped) { + return Ok(path.clone()); + } + } + imports + .get(specifier) + .cloned() + .ok_or_else(|| ProfileArchiveError::ImportNotDeclared(specifier.to_string())) +} + +fn validate_archive_import_map_entry( + key: &str, + path: &str, + manifest_paths: &BTreeSet, +) -> Result<(), ProfileArchiveError> { + if !manifest_paths.contains(path) { + return Err(ProfileArchiveError::MissingSource(path.to_string())); + } + if let Some((current_key, specifier)) = key.split_once('\0') { + validate_archive_path(current_key)?; + let expected = resolve_archive_import_path(current_key, specifier)?; + if expected != path { + return Err(ProfileArchiveError::ImportMapMismatch { + specifier: key.to_string(), + expected, + actual: path.to_string(), + }); + } + } else { + validate_archive_import_specifier(key)?; + } + Ok(()) +} + +fn resolve_archive_import_path( + current_key: &str, + specifier: &str, +) -> Result { + validate_archive_path(current_key)?; + validate_archive_import_specifier(specifier)?; + let raw = if let Some(rest) = specifier.strip_prefix("project:") { + rest + } else if let Some(rest) = specifier.strip_prefix("workspace:") { + rest + } else { + specifier + }; + let base = if raw.starts_with("./") || raw.starts_with("../") { + Path::new(current_key) + .parent() + .map(Path::to_path_buf) + .unwrap_or_default() + .join(raw) + } else { + PathBuf::from(raw) + }; + normalize_archive_virtual_path(&base.to_string_lossy()) +} + +fn validate_archive_import_specifier(specifier: &str) -> Result<(), ProfileArchiveError> { + if specifier.is_empty() + || specifier.contains("://") + || specifier.starts_with('/') + || Path::new(specifier).is_absolute() + { + return Err(ProfileArchiveError::UnsafePath(specifier.to_string())); + } + if let Some((namespace, rest)) = specifier.split_once(':') { + if namespace != "project" && namespace != "workspace" { + return Err(ProfileArchiveError::UnsafePath(specifier.to_string())); + } + if rest.is_empty() || rest.starts_with('/') || rest.contains("://") { + return Err(ProfileArchiveError::UnsafePath(specifier.to_string())); + } + } + Ok(()) +} + +fn normalize_archive_virtual_path(path: &str) -> Result { + let path_ref = Path::new(path); + if path_ref.is_absolute() { + return Err(ProfileArchiveError::UnsafePath(path.to_string())); + } + let mut out = PathBuf::new(); + for component in path_ref.components() { + match component { + Component::CurDir => {} + Component::Normal(value) => out.push(value), + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(ProfileArchiveError::UnsafePath(path.to_string())); + } + } + } + let normalized = out.to_string_lossy().replace('\\', "/"); + validate_archive_path(&normalized)?; + Ok(normalized) +} + fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value { match data { decodal::Data::Bool(value) => serde_json::Value::Bool(*value), @@ -551,4 +721,56 @@ mod tests { let mut loader = ArchiveSourceLoader::new(&verified); assert!(loader.load(None, "missing").is_err()); } + + #[test] + fn archive_loader_resolves_scoped_virtual_imports() { + let mut entrypoints = BTreeMap::new(); + entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string()); + let mut imports = BTreeMap::new(); + imports.insert( + "profiles/main.dcdl\0./shared.dcdl".to_string(), + "profiles/shared.dcdl".to_string(), + ); + let mut sources = BTreeMap::new(); + sources.insert("profiles/main.dcdl".to_string(), "{}".to_string()); + sources.insert("profiles/shared.dcdl".to_string(), "{}".to_string()); + let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: "project".to_string(), + entrypoints, + imports, + sources, + }) + .unwrap(); + let verified = archive.verify().unwrap(); + let mut loader = ArchiveSourceLoader::new(&verified); + let loaded = loader + .load(Some("profiles/main.dcdl"), "./shared.dcdl") + .unwrap(); + assert_eq!(loaded.key, "profiles/shared.dcdl"); + } + + #[test] + fn archive_verify_rejects_import_map_that_bypasses_virtual_resolver() { + let mut entrypoints = BTreeMap::new(); + entrypoints.insert("default".to_string(), "profiles/main.dcdl".to_string()); + let mut imports = BTreeMap::new(); + imports.insert( + "profiles/main.dcdl\0./shared.dcdl".to_string(), + "profiles/other.dcdl".to_string(), + ); + let mut sources = BTreeMap::new(); + sources.insert("profiles/main.dcdl".to_string(), "{}".to_string()); + sources.insert("profiles/other.dcdl".to_string(), "{}".to_string()); + let archive = ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: "project".to_string(), + entrypoints, + imports, + sources, + }) + .unwrap(); + assert!(matches!( + archive.verify(), + Err(ProfileArchiveError::ImportMapMismatch { .. }) + )); + } } diff --git a/crates/workspace-server/src/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index f1113064..2475e0b5 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -14,6 +14,8 @@ use crate::{Error, Result}; const PROFILE_REGISTRY_RELATIVE_PATH: &str = ".yoi/profiles.toml"; const PROFILE_SOURCE_ROOT_RELATIVE_PATH: &str = ".yoi/profiles"; +const PROFILE_SOURCE_TREE_ID: &str = "project"; +const PROFILE_SOURCE_TREE_DISPLAY_ROOT: &str = "profiles"; const MAX_PROFILE_SOURCE_BYTES: u64 = 256 * 1024; const BUILTIN_PROFILE_IDS: &[&str] = &[ "builtin:default", @@ -63,6 +65,8 @@ pub struct ProfileSettingsResponse { pub default_profile: Option, pub profiles: Vec, pub sources: Vec, + #[serde(default)] + pub source_trees: Vec, pub diagnostics: Vec, } @@ -92,6 +96,45 @@ pub struct WorkspaceProfileSourceSummary { pub diagnostics: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSourceTreeSummary { + pub source_tree_id: String, + pub label: String, + pub root_path: String, + pub kind: String, + pub editable: bool, + pub revision: String, + pub file_count: usize, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSourceTreeFileSummary { + pub 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 WorkspaceProfileSourceTreeResponse { + pub workspace_id: String, + pub tree: WorkspaceProfileSourceTreeSummary, + pub files: Vec, + pub diagnostics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceProfileSourceTreeFileResponse { + pub workspace_id: String, + pub source_tree_id: String, + pub file: WorkspaceProfileSourceTreeFileSummary, + pub content: String, + pub diagnostics: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WorkspaceProfileSourceDetailResponse { pub workspace_id: String, @@ -137,6 +180,28 @@ pub struct UpdateWorkspaceProfileSourceRequest { pub revision: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WriteWorkspaceProfileTreeFileRequest { + pub path: String, + pub content: String, + #[serde(default)] + pub revision: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DeleteWorkspaceProfileTreeFileRequest { + pub path: String, + pub revision: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReadWorkspaceProfileTreeFileQuery { + pub path: String, +} + #[derive(Debug, Clone, Deserialize)] #[serde(deny_unknown_fields)] pub struct DeleteWorkspaceProfileSourceRequest { @@ -355,6 +420,7 @@ pub fn load_profile_settings(workspace_id: &str, workspace_root: &Path) -> Profi default_profile: registry.default, profiles, sources, + source_trees: vec![profile_source_tree_summary(workspace_root)], diagnostics, } } @@ -580,6 +646,115 @@ pub fn delete_profile_source( }) } +pub fn read_profile_source_tree( + workspace_id: &str, + workspace_root: &Path, + source_tree_id: &str, +) -> Result { + ensure_profile_source_tree_id(source_tree_id)?; + let files = list_profile_tree_files(workspace_root)?; + Ok(WorkspaceProfileSourceTreeResponse { + workspace_id: workspace_id.to_string(), + tree: profile_source_tree_summary_with_count(workspace_root, files.len()), + files, + diagnostics: Vec::new(), + }) +} + +pub fn read_profile_tree_file( + workspace_id: &str, + workspace_root: &Path, + source_tree_id: &str, + query: ReadWorkspaceProfileTreeFileQuery, +) -> Result { + ensure_profile_source_tree_id(source_tree_id)?; + let relative_path = relative_source_path_for_virtual_path(&query.path)?; + let full = checked_source_path(workspace_root, &relative_path)?; + let metadata = source_metadata(&full)?; + if metadata.len() > MAX_PROFILE_SOURCE_BYTES { + return Err(profile_validation_error( + "profile_source_too_large", + "Profile source is too large for browser editing", + )); + } + let content = fs::read_to_string(&full)?; + Ok(WorkspaceProfileSourceTreeFileResponse { + workspace_id: workspace_id.to_string(), + source_tree_id: source_tree_id.to_string(), + file: summarize_tree_file(&full, display_source_path(&relative_path)), + content, + diagnostics: Vec::new(), + }) +} + +pub fn write_profile_tree_file( + workspace_id: &str, + workspace_root: &Path, + source_tree_id: &str, + request: WriteWorkspaceProfileTreeFileRequest, +) -> Result { + ensure_profile_source_tree_id(source_tree_id)?; + if request.content.as_bytes().len() as u64 > MAX_PROFILE_SOURCE_BYTES { + return Err(profile_validation_error( + "profile_source_too_large", + "Profile source exceeds the browser editing size limit", + )); + } + let relative_path = relative_source_path_for_virtual_path(&request.path)?; + let full = workspace_root.join(&relative_path); + if let Some(parent) = full.parent() { + fs::create_dir_all(parent)?; + } + let full = checked_source_path(workspace_root, &relative_path)?; + if let Some(expected) = request.revision.as_deref() { + ensure_revision(&full, expected, "profile_source_revision_conflict")?; + } else if full.exists() { + return Err(profile_validation_error( + "profile_source_revision_required", + "Existing profile source edits require a revision token", + )); + } + validate_tree_content(workspace_root, &relative_path, &request.content)?; + fs::write(&full, &request.content)?; + Ok(WorkspaceProfileSourceTreeFileResponse { + workspace_id: workspace_id.to_string(), + source_tree_id: source_tree_id.to_string(), + file: summarize_tree_file(&full, display_source_path(&relative_path)), + content: request.content, + diagnostics: vec![diagnostic( + "profile_source_file_written", + DiagnosticSeverity::Info, + "Profile source file was written through the source tree API.", + )], + }) +} + +pub fn delete_profile_tree_file( + workspace_id: &str, + workspace_root: &Path, + source_tree_id: &str, + request: DeleteWorkspaceProfileTreeFileRequest, +) -> Result { + ensure_profile_source_tree_id(source_tree_id)?; + let relative_path = relative_source_path_for_virtual_path(&request.path)?; + let full = checked_source_path(workspace_root, &relative_path)?; + ensure_revision(&full, &request.revision, "profile_source_revision_conflict")?; + let registry = read_registry(workspace_root).map_err(profile_registry_error)?; + if project_entries(®istry, &mut Vec::new()) + .iter() + .any(|entry| entry.relative_path == relative_path) + { + return Err(profile_validation_error( + "profile_source_registered", + "Registered profile entry sources must be deleted through the profile registry API", + )); + } + if full.exists() { + fs::remove_file(full)?; + } + read_profile_source_tree(workspace_id, workspace_root, source_tree_id) +} + pub fn build_workspace_profile_archive( workspace_root: &Path, selector: &str, @@ -588,43 +763,8 @@ pub fn build_workspace_profile_archive( return Ok(None); } 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()) { - 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(), - })?; + let sources = read_profile_source_tree_contents(workspace_root)?; + let (archive, _) = build_profile_archive_from_tree_sources(®istry, sources)?; archive .verify() .and_then(|verified| { @@ -845,6 +985,305 @@ fn validate_registry_default(registry: &ProfileRegistryDocument) -> Result<()> { )) } +fn profile_source_tree_summary(workspace_root: &Path) -> WorkspaceProfileSourceTreeSummary { + let file_count = list_profile_tree_files(workspace_root) + .map(|files| files.len()) + .unwrap_or(0); + profile_source_tree_summary_with_count(workspace_root, file_count) +} + +fn profile_source_tree_summary_with_count( + workspace_root: &Path, + file_count: usize, +) -> WorkspaceProfileSourceTreeSummary { + WorkspaceProfileSourceTreeSummary { + source_tree_id: PROFILE_SOURCE_TREE_ID.to_string(), + label: "Project profile sources".to_string(), + root_path: PROFILE_SOURCE_TREE_DISPLAY_ROOT.to_string(), + kind: "decodal_source_tree".to_string(), + editable: true, + revision: file_revision(&workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH)), + file_count, + diagnostics: Vec::new(), + } +} + +fn ensure_profile_source_tree_id(source_tree_id: &str) -> Result<()> { + if source_tree_id == PROFILE_SOURCE_TREE_ID { + Ok(()) + } else { + Err(Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "unknown_profile_source_tree".to_string(), + message: "Unknown profile source tree".to_string(), + }) + } +} + +fn list_profile_tree_files( + workspace_root: &Path, +) -> Result> { + let mut files = Vec::new(); + collect_profile_tree_files( + workspace_root, + &workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH), + &mut files, + )?; + files.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(files) +} + +fn collect_profile_tree_files( + workspace_root: &Path, + dir: &Path, + files: &mut Vec, +) -> Result<()> { + if !dir.exists() { + return Ok(()); + } + for entry in fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + let file_type = entry.file_type()?; + if file_type.is_dir() { + collect_profile_tree_files(workspace_root, &path, files)?; + } else if file_type.is_file() + && path.extension().and_then(|value| value.to_str()) == Some("dcdl") + { + let relative = path + .strip_prefix(workspace_root) + .map_err(|_| { + profile_validation_error( + "profile_source_path_invalid", + "Profile source is outside workspace", + ) + })? + .to_path_buf(); + files.push(summarize_tree_file(&path, display_source_path(&relative))); + } + } + Ok(()) +} + +fn summarize_tree_file(path: &Path, virtual_path: String) -> WorkspaceProfileSourceTreeFileSummary { + WorkspaceProfileSourceTreeFileSummary { + path: virtual_path, + kind: "decodal".to_string(), + editable: true, + revision: file_revision(path), + size_bytes: source_metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0), + diagnostics: Vec::new(), + } +} + +fn relative_source_path_for_virtual_path(virtual_path: &str) -> Result { + let normalized = normalize_virtual_profile_source_path(virtual_path)?; + let Some(rest) = normalized.strip_prefix("profiles/") else { + return Err(profile_validation_error( + "profile_source_path_invalid", + "Profile source paths must be under profiles/", + )); + }; + if rest.is_empty() || !rest.ends_with(".dcdl") { + return Err(profile_validation_error( + "profile_source_path_invalid", + "Profile source files must use the .dcdl extension", + )); + } + Ok(Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH).join(rest)) +} + +fn display_source_path(relative_path: &Path) -> String { + let profile_root = Path::new(PROFILE_SOURCE_ROOT_RELATIVE_PATH); + let relative = relative_path + .strip_prefix(profile_root) + .unwrap_or(relative_path); + let rest = relative.to_string_lossy().replace('\\', "/"); + format!("{PROFILE_SOURCE_TREE_DISPLAY_ROOT}/{rest}") +} + +fn normalize_virtual_profile_source_path(path: &str) -> Result { + let path = path + .strip_prefix("project:") + .or_else(|| path.strip_prefix("workspace:")) + .unwrap_or(path); + if path.is_empty() || path.contains("://") || Path::new(path).is_absolute() { + return Err(profile_validation_error( + "profile_source_path_invalid", + "Profile source path must be a virtual relative path", + )); + } + let mut normalized = PathBuf::new(); + for component in Path::new(path).components() { + match component { + Component::CurDir => {} + Component::Normal(value) => normalized.push(value), + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(profile_validation_error( + "profile_source_path_invalid", + "Profile source path must not escape the virtual source tree", + )); + } + } + } + let normalized = normalized.to_string_lossy().replace('\\', "/"); + if normalized.is_empty() { + return Err(profile_validation_error( + "profile_source_path_invalid", + "Profile source path must not be empty", + )); + } + Ok(normalized) +} + +fn read_profile_source_tree_contents(workspace_root: &Path) -> Result> { + let mut sources = BTreeMap::new(); + for file in list_profile_tree_files(workspace_root)? { + let relative = relative_source_path_for_virtual_path(&file.path)?; + let full = checked_source_path(workspace_root, &relative)?; + sources.insert(file.path, fs::read_to_string(full)?); + } + Ok(sources) +} + +fn build_profile_archive_from_tree_sources( + registry: &ProfileRegistryDocument, + mut sources: BTreeMap, +) -> Result<(ProfileSourceArchive, BTreeMap)> { + let mut entrypoints = BTreeMap::new(); + for entry in project_entries(registry, &mut Vec::new()) { + let path = display_source_path(&entry.relative_path); + if sources.contains_key(&path) { + entrypoints.insert(project_selector(&entry.name), path); + } + } + 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 mut imports = BTreeMap::new(); + let source_snapshot = sources.clone(); + for (current_path, content) in &source_snapshot { + for specifier in collect_decodal_import_specifiers(content) { + let target = resolve_profile_source_import(current_path, &specifier)?; + if source_snapshot.contains_key(&target) { + imports.insert(format!("{current_path}\0{specifier}"), target); + } else { + return Err(profile_validation_error( + "profile_source_import_missing", + &format!( + "Profile source import {specifier:?} from {current_path} resolves to missing {target}" + ), + )); + } + } + } + if sources.is_empty() { + sources.insert("profiles/.empty.dcdl".to_string(), "{}".to_string()); + } + ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: "workspace-project-decodal-profiles-v1".to_string(), + entrypoints, + imports: imports.clone(), + sources, + }) + .map(|archive| (archive, imports)) + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_archive_invalid".to_string(), + message: err.to_string(), + }) +} + +fn resolve_profile_source_import(current_path: &str, specifier: &str) -> Result { + if specifier.is_empty() || specifier.contains("://") || Path::new(specifier).is_absolute() { + return Err(profile_validation_error( + "profile_source_import_invalid", + "Profile source import must be a virtual relative path", + )); + } + let raw = specifier + .strip_prefix("project:") + .or_else(|| specifier.strip_prefix("workspace:")) + .unwrap_or(specifier); + if specifier.contains(':') && raw == specifier { + return Err(profile_validation_error( + "profile_source_import_invalid", + "Unsupported profile source import namespace", + )); + } + let base = if raw.starts_with("profiles/") || raw.starts_with("./profiles/") { + PathBuf::from(raw) + } else { + Path::new(current_path) + .parent() + .map(Path::to_path_buf) + .unwrap_or_default() + .join(raw) + }; + let normalized = normalize_virtual_profile_source_path(&base.to_string_lossy())?; + if !normalized.starts_with("profiles/") { + return Err(profile_validation_error( + "profile_source_import_invalid", + "Profile source import must resolve inside the profiles/ tree", + )); + } + Ok(normalized) +} + +fn collect_decodal_import_specifiers(content: &str) -> Vec { + let mut specifiers = Vec::new(); + for line in content.lines() { + let trimmed = line.trim_start(); + if trimmed.starts_with("//") || trimmed.starts_with('#') { + continue; + } + let Some(index) = trimmed.find("import") else { + continue; + }; + let after = trimmed[index + "import".len()..].trim_start(); + let Some(first) = after.chars().next() else { + continue; + }; + if first == '"' || first == '\'' { + if let Some(end) = after[1..].find(first) { + specifiers.push(after[1..1 + end].to_string()); + } + } else { + let ident: String = after + .chars() + .take_while(|ch| !ch.is_whitespace() && *ch != ';' && *ch != ',' && *ch != '{') + .collect(); + if !ident.is_empty() { + specifiers.push(ident); + } + } + } + specifiers +} + +fn validate_tree_content(workspace_root: &Path, relative_path: &Path, content: &str) -> Result<()> { + let mut sources = read_profile_source_tree_contents(workspace_root)?; + sources.insert(display_source_path(relative_path), content.to_string()); + let registry = read_registry(workspace_root).map_err(profile_registry_error)?; + let (archive, _) = build_profile_archive_from_tree_sources(®istry, sources)?; + archive + .verify() + .map_err(|err| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_invalid".to_string(), + message: err.to_string(), + })?; + Ok(()) +} + fn validate_source_content( workspace_root: &Path, name: &str, @@ -858,24 +1297,18 @@ fn validate_source_content( message: "Profile source exceeds the browser editing size limit".to_string(), }); } - let archive_path = archive_path_for_entry(name); + checked_source_path(workspace_root, relative_path)?; + let mut sources = read_profile_source_tree_contents(workspace_root)?; + sources.insert(display_source_path(relative_path), content.to_string()); + let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; + registry.profile.entry(name.to_string()).or_insert_with(|| { + ProfileEntryFile::Table(ProfileEntryTable { + path: display_source_path(relative_path), + description: None, + }) + }); 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 (archive, _) = build_profile_archive_from_tree_sources(®istry, sources)?; let verified = archive .verify() .map_err(|err| Error::RuntimeOperationFailed { @@ -883,7 +1316,6 @@ fn validate_source_content( 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 { @@ -1179,18 +1611,6 @@ pub fn selector_for_builtin_candidate( } } -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) } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index ffa67b8a..a4d33e96 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -38,8 +38,9 @@ use crate::observation::{ }; use crate::profile_settings::{ CreateWorkspaceProfileSourceRequest, DeleteWorkspaceProfileSourceRequest, + DeleteWorkspaceProfileTreeFileRequest, ReadWorkspaceProfileTreeFileQuery, UpdateWorkspaceMetadataRequest, UpdateWorkspaceProfileRegistryRequest, - UpdateWorkspaceProfileSourceRequest, + UpdateWorkspaceProfileSourceRequest, WriteWorkspaceProfileTreeFileRequest, }; use crate::records::{ LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, @@ -281,6 +282,16 @@ pub fn build_router(api: WorkspaceApi) -> Router { "/api/w/{workspace_id}/settings/profiles/registry", put(scoped_update_profile_registry), ) + .route( + "/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}", + get(scoped_get_profile_source_tree), + ) + .route( + "/api/w/{workspace_id}/settings/profiles/trees/{source_tree_id}/file", + get(scoped_get_profile_tree_file) + .put(scoped_write_profile_tree_file) + .delete(scoped_delete_profile_tree_file), + ) .route( "/api/w/{workspace_id}/settings/profiles/{profile_source_id}", get(scoped_get_profile_source) @@ -907,6 +918,60 @@ async fn scoped_update_profile_registry( )?)) } +async fn scoped_get_profile_source_tree( + State(api): State, + AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::read_profile_source_tree( + &workspace_id, + &api.config.workspace_root, + &source_tree_id, + )?)) +} + +async fn scoped_get_profile_tree_file( + State(api): State, + AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, + Query(query): Query, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::read_profile_tree_file( + &workspace_id, + &api.config.workspace_root, + &source_tree_id, + query, + )?)) +} + +async fn scoped_write_profile_tree_file( + State(api): State, + AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::write_profile_tree_file( + &workspace_id, + &api.config.workspace_root, + &source_tree_id, + request, + )?)) +} + +async fn scoped_delete_profile_tree_file( + State(api): State, + AxumPath((workspace_id, source_tree_id)): AxumPath<(String, String)>, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &workspace_id)?; + Ok(Json(crate::profile_settings::delete_profile_tree_file( + &workspace_id, + &api.config.workspace_root, + &source_tree_id, + request, + )?)) +} + async fn scoped_get_profile_source( State(api): State, AxumPath((workspace_id, profile_source_id)): AxumPath<(String, String)>, diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 0511dfa3..74c4df10 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -15,6 +15,8 @@ "@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@3.0.9", "@sveltejs/kit": "npm:@sveltejs/kit@2.49.4", "@sveltejs/vite-plugin-svelte": "npm:@sveltejs/vite-plugin-svelte@6.2.1", + "@codemirror/state": "npm:@codemirror/state@6.5.2", + "@codemirror/view": "npm:@codemirror/view@6.38.8", "clsx": "npm:clsx@2.1.1", "cookie": "npm:cookie@0.6.0", "devalue": "npm:devalue@5.6.4", diff --git a/web/workspace/deno.lock b/web/workspace/deno.lock index aa3b468c..8778ced0 100644 --- a/web/workspace/deno.lock +++ b/web/workspace/deno.lock @@ -1,6 +1,8 @@ { "version": "5", "specifiers": { + "npm:@codemirror/state@6.5.2": "6.5.2", + "npm:@codemirror/view@6.38.8": "6.38.8", "npm:@sveltejs/adapter-static@3.0.9": "3.0.9_@sveltejs+kit@2.49.4__@sveltejs+vite-plugin-svelte@6.2.1___svelte@5.45.6___vite@7.2.7__svelte@5.45.6__typescript@5.9.3__vite@7.2.7_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7", "npm:@sveltejs/kit@2.49.4": "2.49.4_@sveltejs+vite-plugin-svelte@6.2.1__svelte@5.45.6__vite@7.2.7_svelte@5.45.6_typescript@5.9.3_vite@7.2.7", "npm:@sveltejs/vite-plugin-svelte@6.2.1": "6.2.1_svelte@5.45.6_vite@7.2.7", @@ -14,6 +16,21 @@ "npm:vite@7.2.7": "7.2.7" }, "npm": { + "@codemirror/state@6.5.2": { + "integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==", + "dependencies": [ + "@marijn/find-cluster-break" + ] + }, + "@codemirror/view@6.38.8": { + "integrity": "sha512-XcE9fcnkHCbWkjeKyi0lllwXmBLtyYb5dt89dJyx23I9+LSh5vZDIuk7OLG4VM1lgrXZQcY6cxyZyk5WVPRv/A==", + "dependencies": [ + "@codemirror/state", + "crelt", + "style-mod", + "w3c-keyname" + ] + }, "@esbuild/aix-ppc64@0.25.12": { "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", "os": ["aix"], @@ -171,6 +188,9 @@ "@jridgewell/sourcemap-codec" ] }, + "@marijn/find-cluster-break@1.0.3": { + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==" + }, "@polka/url@1.0.0-next.29": { "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==" }, @@ -392,6 +412,9 @@ "cookie@0.6.0": { "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==" }, + "crelt@1.0.7": { + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==" + }, "debug@4.4.3": { "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "dependencies": [ @@ -567,6 +590,9 @@ "source-map-js@1.2.1": { "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, + "style-mod@4.1.3": { + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==" + }, "svelte-check@4.3.4_svelte@5.45.6_typescript@5.9.3": { "integrity": "sha512-DVWvxhBrDsd+0hHWKfjP99lsSXASeOhHJYyuKOFYJcP7ThfSCKgjVarE8XfuMWpS5JV3AlDf+iK1YGGo2TACdw==", "dependencies": [ @@ -638,12 +664,17 @@ "vite" ] }, + "w3c-keyname@2.2.8": { + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, "zimmerframe@1.1.4": { "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==" } }, "workspace": { "dependencies": [ + "npm:@codemirror/state@6.5.2", + "npm:@codemirror/view@6.38.8", "npm:@sveltejs/adapter-static@3.0.9", "npm:@sveltejs/kit@2.49.4", "npm:@sveltejs/vite-plugin-svelte@6.2.1", diff --git a/web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte b/web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte new file mode 100644 index 00000000..bd300a60 --- /dev/null +++ b/web/workspace/src/lib/workspace-settings/DecodalSourceEditor.svelte @@ -0,0 +1,71 @@ + + +
diff --git a/web/workspace/src/lib/workspace-settings/profile-api.ts b/web/workspace/src/lib/workspace-settings/profile-api.ts index 3b3df0c3..f03dbe75 100644 --- a/web/workspace/src/lib/workspace-settings/profile-api.ts +++ b/web/workspace/src/lib/workspace-settings/profile-api.ts @@ -8,6 +8,8 @@ import type { WorkspaceMetadataMutationResponse, WorkspaceMetadataSettingsResponse, WorkspaceProfileSourceDetailResponse, + WorkspaceProfileSourceTreeFileResponse, + WorkspaceProfileSourceTreeResponse, } from "./profile-types"; export function fetchWorkspaceMetadataSettings( @@ -116,3 +118,44 @@ export function deleteProfileSource( { method: "DELETE", body: JSON.stringify(request) }, ); } + +export function fetchProfileSourceTree( + workspaceId: string, + sourceTreeId: string, +): Promise { + return workspaceApiJson( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}`, + ); +} + +export function fetchProfileTreeFile( + workspaceId: string, + sourceTreeId: string, + path: string, +): Promise { + return workspaceApiJson( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file?path=${encodeURIComponent(path)}`, + ); +} + +export function writeProfileTreeFile( + workspaceId: string, + sourceTreeId: string, + request: { path: string; content: string; revision?: string | null }, +): Promise { + return workspaceApiJsonWithBody( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`, + { method: "PUT", body: JSON.stringify(request) }, + ); +} + +export function deleteProfileTreeFile( + workspaceId: string, + sourceTreeId: string, + request: { path: string; revision: string }, +): Promise { + return workspaceApiJsonWithBody( + `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles/trees/${encodeURIComponent(sourceTreeId)}/file`, + { 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 index d421f2f6..5508e81f 100644 --- a/web/workspace/src/lib/workspace-settings/profile-types.ts +++ b/web/workspace/src/lib/workspace-settings/profile-types.ts @@ -42,6 +42,7 @@ export type ProfileSettingsResponse = { default_profile?: string | null; profiles: WorkspaceProfileSummary[]; sources: WorkspaceProfileSourceSummary[]; + source_trees: WorkspaceProfileSourceTreeSummary[]; diagnostics: Diagnostic[]; }; @@ -58,3 +59,38 @@ export type ProfileSettingsMutationResponse = { settings: ProfileSettingsResponse; diagnostics: Diagnostic[]; }; + +export type WorkspaceProfileSourceTreeSummary = { + source_tree_id: string; + label: string; + root_path: string; + kind: "decodal_source_tree" | string; + editable: boolean; + revision: string; + file_count: number; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceTreeFileSummary = { + path: string; + kind: "decodal" | string; + editable: boolean; + revision: string; + size_bytes: number; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceTreeResponse = { + workspace_id: string; + tree: WorkspaceProfileSourceTreeSummary; + files: WorkspaceProfileSourceTreeFileSummary[]; + diagnostics: Diagnostic[]; +}; + +export type WorkspaceProfileSourceTreeFileResponse = { + workspace_id: string; + source_tree_id: string; + file: WorkspaceProfileSourceTreeFileSummary; + content: string; + diagnostics: Diagnostic[]; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte index 2cd14807..89e84377 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/+page.svelte @@ -1,40 +1,88 @@ + + + Profile source tree · Yoi Workspace + + +
+
+
+

Profile source tree

+

{sourceTreeId}

+
+ Back to profiles +
+ + {#if loading} +

Loading source tree…

+ {:else if tree} +

+ {tree.tree.root_path} · {tree.tree.file_count} files · {tree.tree.content_type} · {tree.tree.content_digest} +

+
+ + +
+
+
+

Files

+
    + {#each tree.files as file (file.path)} +
  • + + {file.content_type} · {file.content_digest} + {file.size_bytes} bytes · rev {file.revision} + +
  • + {/each} +
+
+ {#if selectedFile} +
+
+
+

{selectedFile.file.content_type}

+

{selectedFile.file.path}

+
+
+ + +
+
+ (draftContent = value)} ariaLabel={`Decodal source ${selectedFile.file.path}`} /> +
+ {/if} +
+ {/if} + + {#if message}

{message}

{/if} + +
diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/profiles/trees/[sourceTreeId]/+page.ts b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/trees/[sourceTreeId]/+page.ts new file mode 100644 index 00000000..c8d0ef85 --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/settings/profiles/trees/[sourceTreeId]/+page.ts @@ -0,0 +1,5 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = ({ params }) => ({ + sourceTreeId: params.sourceTreeId, +}); From c5a9f8a0ab5d6774a70fdb5f2ccf9fabd5e0f078 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Jul 2026 17:52:24 +0900 Subject: [PATCH 07/12] ticket: record profile source tree review fixes --- .yoi/tickets/00001KX1JNJ2Y/item.md | 2 +- .yoi/tickets/00001KX1JNJ2Y/thread.md | 29 ++++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX1JNJ2Y/item.md b/.yoi/tickets/00001KX1JNJ2Y/item.md index 56eaeac3..5605b9b2 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/item.md +++ b/.yoi/tickets/00001KX1JNJ2Y/item.md @@ -2,7 +2,7 @@ title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' state: 'inprogress' created_at: '2026-07-08T19:18:25Z' -updated_at: '2026-07-09T08:32:35Z' +updated_at: '2026-07-09T08:52:14Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-09T07:56:07Z' diff --git a/.yoi/tickets/00001KX1JNJ2Y/thread.md b/.yoi/tickets/00001KX1JNJ2Y/thread.md index a7d6a28f..7935c581 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/thread.md +++ b/.yoi/tickets/00001KX1JNJ2Y/thread.md @@ -220,3 +220,32 @@ Non-blocking follow-ups: - After blockers are fixed, add focused regression coverage for selected-root closure behavior, tree route smoke, Decodal editor extension wiring, UI create/delete flows, and symlinked-directory tree-write rejection-before-side-effect。 --- + + + +## Implementation report + +Review-fix implementation report: + +- Coder Pod completed requested changes and committed follow-up `941b9126 fix: complete profile source tree review fixes` on branch `work/00001KX1JNJ2Y-profile-source-tree`. +- Worktree status was clean after commit。 +- Fix summary: + - Added `decodal-codemirror@0.1.2` and wired `decodal()` into the CodeMirror 6 editor。 + - Added source tree settings route `/w//settings/profiles/trees/`。 + - Wired UI create/save/delete operations through Backend virtual-path source tree APIs。 + - Changed archive construction to selected selector/root + import closure only, avoiding unrelated project tree sources。 + - Added safe typed source/tree metadata including content type, content digest, and typed provenance。 + - Hardened tree writes against symlinked intermediate directories before any outside filesystem mutation。 + - Added focused regression coverage for selected-root closure-only archive contents, symlink write rejection before side effects, and web model smoke tests for tree routes / virtual create paths。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cargo test -p yoi-workspace-server`: pass(93 lib tests + 2 main tests) + - `cargo test -p worker-runtime --features ws-server,fs-store`: pass(47 lib tests + 5 main tests + doc tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(19 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass +- Requesting follow-up external review against `81abfa63..941b9126` and full combined implementation。 + +--- From 2dfad3500f317a6820fcd0df35004a178645827b Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Jul 2026 17:57:33 +0900 Subject: [PATCH 08/12] ticket: record profile source tree symlink blocker --- .yoi/tickets/00001KX1JNJ2Y/item.md | 2 +- .yoi/tickets/00001KX1JNJ2Y/thread.md | 33 ++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX1JNJ2Y/item.md b/.yoi/tickets/00001KX1JNJ2Y/item.md index 5605b9b2..f517280e 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/item.md +++ b/.yoi/tickets/00001KX1JNJ2Y/item.md @@ -2,7 +2,7 @@ title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' state: 'inprogress' created_at: '2026-07-08T19:18:25Z' -updated_at: '2026-07-09T08:52:14Z' +updated_at: '2026-07-09T08:57:15Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-09T07:56:07Z' diff --git a/.yoi/tickets/00001KX1JNJ2Y/thread.md b/.yoi/tickets/00001KX1JNJ2Y/thread.md index 7935c581..474d9676 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/thread.md +++ b/.yoi/tickets/00001KX1JNJ2Y/thread.md @@ -249,3 +249,36 @@ Review-fix implementation report: - Requesting follow-up external review against `81abfa63..941b9126` and full combined implementation。 --- + + + +## Review: request changes + +External follow-up review result: request_changes + +Blockers: + +1. Tree writes still allow a source-root symlink escape before/at filesystem mutation。 + - Fix rejects symlinked intermediate directories under `.yoi/profiles`, but not `.yoi/profiles` itself being a symlink。 + - `prepare_source_path_for_write()` creates/canonicalizes `source_root` without checking `symlink_metadata(source_root)` or verifying canonical source root remains under canonical workspace root。 + - If `.yoi/profiles` is a symlink to an outside directory, `canonical_root` becomes outside target; `parent_relative` strips against textual symlink path and missing children can be created outside workspace。 + - `checked_source_path()` repeats the escaped-root comparison and can accept files under escaped root。 + - `list_profile_tree_files()` starts `read_dir` at `workspace_root/.yoi/profiles`, so symlinked source root can expose outside `.dcdl` files as virtual tree files。 + - Required fix: reject symlinked source root and ensure canonical source root is confined under canonical workspace root before list/read/write/delete/archive operations。 + +2. Regression coverage for the remaining symlink escape is missing。 + - Existing test covers symlinked child directory only。 + - Add focused regression for `.yoi/profiles -> outside_dir` asserting tree write is rejected before side effects, outside target is not created/modified, and preferably list/read/archive construction also fail closed or do not traverse escaped root。 + +Evidence / resolved items: +- `decodal-codemirror@0.1.2` is present and `DecodalSourceEditor.svelte` imports/uses `decodal()` in CM6 extensions。 +- Required tree route exists at `web/workspace/src/routes/w/[workspaceId]/settings/profiles/trees/[sourceTreeId]/...`; create/save/delete call Backend APIs。 +- Selected-root archive construction now collects only import closure; tests assert unrelated sources are excluded。 +- Browser-facing typed summaries include virtual path/root, content type/digest, and typed provenance without host paths。 +- Runtime `ArchiveSourceLoader` remains manifest/import-map backed with no filesystem fallback。 + +Non-blocking follow-ups: +- Current web tests cover route helper/path normalization but not actual Svelte create/save/delete interaction behavior。 +- Editor extension wiring is compile/static-source covered rather than directly unit-tested; acceptable for now, but smoke coverage would help future dependency regressions。 + +--- From 13c6548b461a75515b9fdf392ddddbf65d517b75 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Jul 2026 18:08:31 +0900 Subject: [PATCH 09/12] fix: reject profile source root symlinks --- .yoi/tickets/00001KX1JNJ2Y/item.md | 2 +- .yoi/tickets/00001KX1JNJ2Y/thread.md | 24 ++ .../workspace-server/src/profile_settings.rs | 238 +++++++++++++++--- 3 files changed, 223 insertions(+), 41 deletions(-) diff --git a/.yoi/tickets/00001KX1JNJ2Y/item.md b/.yoi/tickets/00001KX1JNJ2Y/item.md index 7aff46ff..7bbd029f 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/item.md +++ b/.yoi/tickets/00001KX1JNJ2Y/item.md @@ -2,7 +2,7 @@ title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' state: 'inprogress' created_at: '2026-07-08T19:18:25Z' -updated_at: '2026-07-09T08:46:58Z' +updated_at: '2026-07-09T09:08:18Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-09T07:56:07Z' diff --git a/.yoi/tickets/00001KX1JNJ2Y/thread.md b/.yoi/tickets/00001KX1JNJ2Y/thread.md index 7c5b9542..eeb270f0 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/thread.md +++ b/.yoi/tickets/00001KX1JNJ2Y/thread.md @@ -174,4 +174,28 @@ Validation: - `nix build .#yoi --no-link` +--- + + + +## Implementation report + +Follow-up fix for source-root symlink review blocker. + +Summary: +- Added a shared ProfileSourceRoot guard that rejects `.yoi/profiles` when it is a symlink and verifies the canonical profile source root remains under the canonical workspace root. +- Applied the guard to source tree listing, tree file read/delete checks, write preparation, source summaries, and archive construction through the existing tree scan/read path. +- Hardened writes so `.yoi` and `.yoi/profiles` are created one component at a time only after symlink/canonical containment checks, preventing outside mutations through a symlinked source root. +- Added regression coverage for `.yoi/profiles -> outside_dir` to assert writes fail before outside side effects and list/archive paths fail closed. + +Validation: +- `git diff --check` +- `cargo test -p yoi-workspace-server` +- `cargo test -p worker-runtime --features ws-server,fs-store` +- `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/profile_settings.rs b/crates/workspace-server/src/profile_settings.rs index 9b3292a1..4f105c68 100644 --- a/crates/workspace-server/src/profile_settings.rs +++ b/crates/workspace-server/src/profile_settings.rs @@ -504,10 +504,7 @@ pub fn create_profile_source( .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)?; - } + let full = prepare_source_path_for_write(workspace_root, &relative_path)?; fs::write(&full, request.content)?; registry.profile.insert( name.clone(), @@ -1039,33 +1036,49 @@ fn ensure_profile_source_tree_id(source_tree_id: &str) -> Result<()> { fn list_profile_tree_files( workspace_root: &Path, ) -> Result> { + let Some(source_root) = existing_profile_source_root(workspace_root)? else { + return Ok(Vec::new()); + }; let mut files = Vec::new(); - collect_profile_tree_files( - workspace_root, - &workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH), - &mut files, - )?; + collect_profile_tree_files(workspace_root, &source_root, &source_root.path, &mut files)?; files.sort_by(|a, b| a.path.cmp(&b.path)); Ok(files) } fn collect_profile_tree_files( workspace_root: &Path, + source_root: &ProfileSourceRoot, dir: &Path, files: &mut Vec, ) -> Result<()> { - if !dir.exists() { - return Ok(()); + let canonical_dir = fs::canonicalize(dir)?; + if !canonical_dir.starts_with(&source_root.canonical_path) { + return Err(profile_source_symlink_escape( + "Profile source directory resolves outside the workspace profile source root", + )); } for entry in fs::read_dir(dir)? { let entry = entry?; let path = entry.path(); let file_type = entry.file_type()?; + if file_type.is_symlink() { + return Err(profile_source_symlink_escape( + "Profile source tree entries must not be symlinks", + )); + } if file_type.is_dir() { - collect_profile_tree_files(workspace_root, &path, files)?; + collect_profile_tree_files(workspace_root, source_root, &path, files)?; } else if file_type.is_file() && path.extension().and_then(|value| value.to_str()) == Some("dcdl") { + let canonical_file = fs::canonicalize(&path)?; + if !canonical_file.starts_with(&source_root.canonical_path) + || !canonical_file.starts_with(&source_root.canonical_workspace) + { + return Err(profile_source_symlink_escape( + "Profile source file resolves outside the workspace profile source root", + )); + } let relative = path .strip_prefix(workspace_root) .map_err(|_| { @@ -1398,7 +1411,7 @@ fn validate_source_content( message: "Profile source exceeds the browser editing size limit".to_string(), }); } - checked_source_path(workspace_root, relative_path)?; + validate_source_candidate_path(workspace_root, relative_path)?; let mut sources = read_profile_source_tree_contents(workspace_root)?; sources.insert(display_source_path(relative_path), content.to_string()); let mut registry = read_registry(workspace_root).map_err(profile_registry_error)?; @@ -1639,6 +1652,120 @@ fn sha256_hex(bytes: &[u8]) -> String { out } +#[derive(Debug, Clone)] +struct ProfileSourceRoot { + path: PathBuf, + canonical_path: PathBuf, + canonical_workspace: PathBuf, +} + +fn profile_source_symlink_escape(message: impl Into) -> Error { + Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "profile_source_symlink_escape".to_string(), + message: message.into(), + } +} + +fn existing_profile_source_root(workspace_root: &Path) -> Result> { + let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH); + let metadata = match fs::symlink_metadata(&source_root) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + validate_profile_source_root_metadata(workspace_root, source_root, metadata).map(Some) +} + +fn prepare_profile_source_root_for_write(workspace_root: &Path) -> Result { + let canonical_workspace = fs::canonicalize(workspace_root)?; + let yoi_dir = workspace_root.join(".yoi"); + match fs::symlink_metadata(&yoi_dir) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(profile_source_symlink_escape( + "Workspace .yoi directory must not be a symlink", + )); + } + if !metadata.is_dir() { + return Err(profile_validation_error( + "profile_source_path_invalid", + "Workspace .yoi path is not a directory", + )); + } + let canonical_yoi = fs::canonicalize(&yoi_dir)?; + if !canonical_yoi.starts_with(&canonical_workspace) { + return Err(profile_source_symlink_escape( + "Workspace .yoi directory resolves outside the workspace root", + )); + } + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => fs::create_dir(&yoi_dir)?, + Err(err) => return Err(err.into()), + } + + let source_root = workspace_root.join(PROFILE_SOURCE_ROOT_RELATIVE_PATH); + match fs::symlink_metadata(&source_root) { + Ok(metadata) => { + validate_profile_source_root_metadata(workspace_root, source_root, metadata) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => { + fs::create_dir(&source_root)?; + let metadata = fs::symlink_metadata(&source_root)?; + validate_profile_source_root_metadata(workspace_root, source_root, metadata) + } + Err(err) => Err(err.into()), + } +} + +fn validate_profile_source_root_metadata( + workspace_root: &Path, + source_root: PathBuf, + metadata: std::fs::Metadata, +) -> Result { + if metadata.file_type().is_symlink() { + return Err(profile_source_symlink_escape( + "Workspace profile source root must not be a symlink", + )); + } + if !metadata.is_dir() { + return Err(profile_validation_error( + "profile_source_path_invalid", + "Workspace profile source root is not a directory", + )); + } + let canonical_workspace = fs::canonicalize(workspace_root)?; + let canonical_path = fs::canonicalize(&source_root)?; + if !canonical_path.starts_with(&canonical_workspace) { + return Err(profile_source_symlink_escape( + "Workspace profile source root resolves outside the workspace root", + )); + } + Ok(ProfileSourceRoot { + path: source_root, + canonical_path, + canonical_workspace, + }) +} + +fn validate_source_candidate_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 Some(_) = existing_profile_source_root(workspace_root)? else { + return Ok(()); + }; + let full = workspace_root.join(relative_path); + if full.exists() || full.parent().is_some_and(Path::exists) { + checked_source_path(workspace_root, relative_path)?; + } + Ok(()) +} + fn prepare_source_path_for_write(workspace_root: &Path, relative_path: &Path) -> Result { validate_relative_source_path(relative_path).map_err(|message| { Error::RuntimeOperationFailed { @@ -1647,9 +1774,7 @@ fn prepare_source_path_for_write(workspace_root: &Path, relative_path: &Path) -> 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 source_root = prepare_profile_source_root_for_write(workspace_root)?; let full = workspace_root.join(relative_path); let parent = full.parent().ok_or_else(|| { profile_validation_error( @@ -1657,13 +1782,13 @@ fn prepare_source_path_for_write(workspace_root: &Path, relative_path: &Path) -> "Profile source path has no parent directory", ) })?; - let parent_relative = parent.strip_prefix(&source_root).map_err(|_| { + let parent_relative = parent.strip_prefix(&source_root.path).map_err(|_| { profile_validation_error( "profile_source_path_escape", "Profile source parent must remain inside the source tree", ) })?; - let mut current = source_root.clone(); + let mut current = source_root.path.clone(); for component in parent_relative.components() { let Component::Normal(name) = component else { return Err(profile_validation_error( @@ -1675,11 +1800,9 @@ fn prepare_source_path_for_write(workspace_root: &Path, relative_path: &Path) -> match fs::symlink_metadata(&next) { Ok(metadata) => { if metadata.file_type().is_symlink() { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_symlink_escape".to_string(), - message: "Profile source parent contains a symlink".to_string(), - }); + return Err(profile_source_symlink_escape( + "Profile source parent contains a symlink", + )); } if !metadata.is_dir() { return Err(profile_validation_error( @@ -1688,12 +1811,12 @@ fn prepare_source_path_for_write(workspace_root: &Path, relative_path: &Path) -> )); } let canonical_next = fs::canonicalize(&next)?; - if !canonical_next.starts_with(&canonical_root) { - return Err(Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "profile_source_symlink_escape".to_string(), - message: "Profile source parent resolves outside the workspace profile source root".to_string(), - }); + if !canonical_next.starts_with(&source_root.canonical_path) + || !canonical_next.starts_with(&source_root.canonical_workspace) + { + return Err(profile_source_symlink_escape( + "Profile source parent resolves outside the workspace profile source root", + )); } } Err(err) if err.kind() == std::io::ErrorKind::NotFound => fs::create_dir(&next)?, @@ -1712,22 +1835,26 @@ fn checked_source_path(workspace_root: &Path, relative_path: &Path) -> Result Date: Thu, 9 Jul 2026 18:10:08 +0900 Subject: [PATCH 10/12] ticket: record profile source tree symlink fix --- .yoi/tickets/00001KX1JNJ2Y/item.md | 2 +- .yoi/tickets/00001KX1JNJ2Y/thread.md | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX1JNJ2Y/item.md b/.yoi/tickets/00001KX1JNJ2Y/item.md index f517280e..f890cec5 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/item.md +++ b/.yoi/tickets/00001KX1JNJ2Y/item.md @@ -2,7 +2,7 @@ title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' state: 'inprogress' created_at: '2026-07-08T19:18:25Z' -updated_at: '2026-07-09T08:57:15Z' +updated_at: '2026-07-09T09:10:00Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-09T07:56:07Z' diff --git a/.yoi/tickets/00001KX1JNJ2Y/thread.md b/.yoi/tickets/00001KX1JNJ2Y/thread.md index 474d9676..2c807aa9 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/thread.md +++ b/.yoi/tickets/00001KX1JNJ2Y/thread.md @@ -282,3 +282,31 @@ Non-blocking follow-ups: - Editor extension wiring is compile/static-source covered rather than directly unit-tested; acceptable for now, but smoke coverage would help future dependency regressions。 --- + + + +## Implementation report + +Second review-fix implementation report: + +- Coder Pod completed remaining requested changes and committed follow-up `13c6548b fix: reject profile source root symlinks` on branch `work/00001KX1JNJ2Y-profile-source-tree`。 +- Worktree status was clean after commit。 +- Fix summary: + - Added shared `ProfileSourceRoot` guard。 + - Rejects `.yoi/profiles` when it is a symlink。 + - Verifies canonical `.yoi/profiles` remains under the canonical workspace root。 + - Applied the guard consistently to source tree listing, file read/delete checks, write preparation, source summaries, and archive construction through tree scan/read paths。 + - Hardened writes so `.yoi` and `.yoi/profiles` are created one component at a time only after symlink/canonical containment checks。 + - Added regression coverage for `.yoi/profiles -> outside_dir`: write rejects before outside side effects, outside target is not created/modified, source tree read/list fails closed, and archive construction fails closed。 +- Orchestrator reran validation in the implementation worktree: + - `git diff --check`: pass + - `cargo test -p yoi-workspace-server`: pass(94 lib tests + 2 main tests) + - `cargo test -p worker-runtime --features ws-server,fs-store`: pass(47 lib tests + 5 main tests + doc tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(19 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass +- Requesting second follow-up external review against `941b9126..13c6548b` and full combined implementation。 + +--- From dccfe539efdda7a7eeace4a1794403e3c1165239 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Jul 2026 18:16:38 +0900 Subject: [PATCH 11/12] ticket: approve profile source tree work --- .yoi/tickets/00001KX1JNJ2Y/item.md | 2 +- .yoi/tickets/00001KX1JNJ2Y/thread.md | 36 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) diff --git a/.yoi/tickets/00001KX1JNJ2Y/item.md b/.yoi/tickets/00001KX1JNJ2Y/item.md index f890cec5..b2e7b20d 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/item.md +++ b/.yoi/tickets/00001KX1JNJ2Y/item.md @@ -2,7 +2,7 @@ title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' state: 'inprogress' created_at: '2026-07-08T19:18:25Z' -updated_at: '2026-07-09T09:10:00Z' +updated_at: '2026-07-09T09:16:20Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-09T07:56:07Z' diff --git a/.yoi/tickets/00001KX1JNJ2Y/thread.md b/.yoi/tickets/00001KX1JNJ2Y/thread.md index 2c807aa9..fbaaae88 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/thread.md +++ b/.yoi/tickets/00001KX1JNJ2Y/thread.md @@ -310,3 +310,39 @@ Second review-fix implementation report: - Requesting second follow-up external review against `941b9126..13c6548b` and full combined implementation。 --- + + + +## Review: approve + +External second follow-up review result: approve + +Blockers verified: +- Source-root symlink rejection is now in the shared source-root path validation: + - `existing_profile_source_root` rejects `.yoi/profiles` when the final component is a symlink。 + - `prepare_profile_source_root_for_write` rejects both `.yoi` symlink parents and `.yoi/profiles` symlink roots before create/write operations。 + - `checked_source_path`, tree listing, file read/write/delete, and archive construction all flow through these guards before touching tree contents。 +- Regression coverage for `.yoi/profiles -> outside_dir` exists: + - `source_root_symlink_is_rejected_before_tree_side_effects` covers write rejection before outside side effects, `read_profile_source_tree` fail-closed behavior, and `build_workspace_profile_archive` fail-closed behavior。 + - Existing symlink-parent / entry symlink tests remain in place for nested escape cases。 + +Still-good items checked and intact: +- Decodal editor wiring remains present via the Svelte editor component using `decodal()` / CodeMirror integration。 +- Profile tree API routes remain wired for tree read and file create/save/delete: + - `/settings/profiles/trees/{source_tree_id}` + - `/settings/profiles/trees/{source_tree_id}/file` +- Virtual source tree summaries still expose virtual metadata/provenance/digest-type fields without host path disclosure。 +- Selected-root archive construction still uses the selected profile entrypoint plus import closure rather than blindly archiving every source。 +- `Runtime ArchiveSourceLoader` behavior remains bounded to archive manifest/import-map package files plus archive-backed imports; no host filesystem fallback was introduced。 + +Validation performed: +- Read Ticket records in `.yoi/tickets/00001KX1JNJ2Y/`, including latest implementation/review context。 +- Inspected latest fix `13c6548b`, diff from `941b9126..13c6548b`, and relevant source files in the implementation worktree only。 +- Reviewed the orchestrator-provided successful validation list; Reviewer did not rerun tests or modify files。 + +Non-blocking follow-up: +- A direct assertion for `read_profile_tree_file` against a root-symlinked `.yoi/profiles` would make the regression matrix even more explicit, but the implementation path already uses the same guarded `checked_source_path`, so this is not a merge blocker。 + +No merge-blocking issues found. + +--- From 36454e831b2b38b74976b98ad8a907222094f348 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Jul 2026 18:18:57 +0900 Subject: [PATCH 12/12] ticket: close profile source tree work --- .yoi/tickets/00001KX1JNJ2Y/item.md | 4 +- .yoi/tickets/00001KX1JNJ2Y/resolution.md | 39 +++++++++++ .yoi/tickets/00001KX1JNJ2Y/thread.md | 83 ++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 .yoi/tickets/00001KX1JNJ2Y/resolution.md diff --git a/.yoi/tickets/00001KX1JNJ2Y/item.md b/.yoi/tickets/00001KX1JNJ2Y/item.md index b2e7b20d..22bfa7b0 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/item.md +++ b/.yoi/tickets/00001KX1JNJ2Y/item.md @@ -1,8 +1,8 @@ --- title: 'Add ProfileSourceTree virtual filesystem and Decodal profile editor' -state: 'inprogress' +state: 'closed' created_at: '2026-07-08T19:18:25Z' -updated_at: '2026-07-09T09:16:20Z' +updated_at: '2026-07-09T09:18:48Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-09T07:56:07Z' diff --git a/.yoi/tickets/00001KX1JNJ2Y/resolution.md b/.yoi/tickets/00001KX1JNJ2Y/resolution.md new file mode 100644 index 00000000..0cb250b3 --- /dev/null +++ b/.yoi/tickets/00001KX1JNJ2Y/resolution.md @@ -0,0 +1,39 @@ +完了。 + +実装内容: +- Backend-owned `ProfileSourceTree` virtual filesystem model を追加した。 +- virtual relative paths / safe source tree ids / file ids / revision / content digest / content type / typed provenance / diagnostics を扱う safe summaries を追加した。 +- Profile source tree list/read/write/create/delete/validate scoped APIs を追加した。 +- virtual import resolver を追加し、relative imports / scoped namespace imports を扱い、absolute path、URL/unsupported scheme、root escape、unsupported namespace、limit excess を fail closed するようにした。 +- `ProfileSourceArchive` construction を selected root/selector + source tree revision + import closure snapshot に変更し、unrelated sources を archive に含めないようにした。 +- Runtime `ArchiveSourceLoader` / archive verification を virtual path / import-map only に強化し、Workspace filesystem/backend fallback を導入しないまま維持した。 +- Settings UI に Profile source tree list と Decodal source editor route を追加した。 +- `decodal-codemirror@0.1.2` と CodeMirror 6 の `decodal()` extension を editor に接続した。 +- UI create/save/delete operations を Backend virtual-path APIs に接続した。 +- `.yoi/profiles` source root symlink と symlink/path escape を list/read/write/delete/archive construction paths で reject し、outside filesystem mutation 前に fail closed するようにした。 +- Focused backend/runtime/web tests を追加した。 + +主な commit / merge: +- implementation: `81abfa63 feat: add profile source tree editor` +- review fix: `941b9126 fix: complete profile source tree review fixes` +- symlink-root fix: `13c6548b fix: reject profile source root symlinks` +- merge into orchestration: `9a2e57fb merge: profile source tree editor` + +Review: +- 初回 external review は request_changes。 +- first follow-up review も request_changes(source-root symlink blocker)。 +- second follow-up review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cargo test -p yoi-workspace-server`: pass(94 lib tests + 2 main tests) +- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(47 lib tests + 5 main tests + doc tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(19 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 済み。 +- Reviewer の non-blocking follow-up: `read_profile_tree_file` に対する root-symlink direct assertion を追加すると regression matrix がさらに明示的になるが、同じ guarded `checked_source_path` を通るため merge blocker ではない。 \ No newline at end of file diff --git a/.yoi/tickets/00001KX1JNJ2Y/thread.md b/.yoi/tickets/00001KX1JNJ2Y/thread.md index fbaaae88..dd818c67 100644 --- a/.yoi/tickets/00001KX1JNJ2Y/thread.md +++ b/.yoi/tickets/00001KX1JNJ2Y/thread.md @@ -346,3 +346,86 @@ Non-blocking follow-up: No merge-blocking issues found. --- + + + +## State changed + +Merge/validation acceptance: + +- Second follow-up external review approved the implementation after symlink-root blocker fix。 +- Implementation branch `work/00001KX1JNJ2Y-profile-source-tree` was merged into the orchestration branch with merge commit `9a2e57fb merge: profile source tree editor`。 +- 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(94 lib tests + 2 main tests) + - `cargo test -p worker-runtime --features ws-server,fs-store`: pass(47 lib tests + 5 main tests + doc tests) + - `cargo check -p yoi`: pass + - `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) + - `cd web/workspace && deno task test`: pass(19 tests) + - `yoi ticket doctor`: ok + - `nix build .#yoi --no-link`: pass + +Implementation commits: +- `81abfa63 feat: add profile source tree editor` +- `941b9126 fix: complete profile source tree review fixes` +- `13c6548b fix: reject profile source root symlinks` + +No merge-blocking issues remain. + +--- + + + +## State changed + +Ticket を closed にしました。 + + +--- + + + +## 完了 + +完了。 + +実装内容: +- Backend-owned `ProfileSourceTree` virtual filesystem model を追加した。 +- virtual relative paths / safe source tree ids / file ids / revision / content digest / content type / typed provenance / diagnostics を扱う safe summaries を追加した。 +- Profile source tree list/read/write/create/delete/validate scoped APIs を追加した。 +- virtual import resolver を追加し、relative imports / scoped namespace imports を扱い、absolute path、URL/unsupported scheme、root escape、unsupported namespace、limit excess を fail closed するようにした。 +- `ProfileSourceArchive` construction を selected root/selector + source tree revision + import closure snapshot に変更し、unrelated sources を archive に含めないようにした。 +- Runtime `ArchiveSourceLoader` / archive verification を virtual path / import-map only に強化し、Workspace filesystem/backend fallback を導入しないまま維持した。 +- Settings UI に Profile source tree list と Decodal source editor route を追加した。 +- `decodal-codemirror@0.1.2` と CodeMirror 6 の `decodal()` extension を editor に接続した。 +- UI create/save/delete operations を Backend virtual-path APIs に接続した。 +- `.yoi/profiles` source root symlink と symlink/path escape を list/read/write/delete/archive construction paths で reject し、outside filesystem mutation 前に fail closed するようにした。 +- Focused backend/runtime/web tests を追加した。 + +主な commit / merge: +- implementation: `81abfa63 feat: add profile source tree editor` +- review fix: `941b9126 fix: complete profile source tree review fixes` +- symlink-root fix: `13c6548b fix: reject profile source root symlinks` +- merge into orchestration: `9a2e57fb merge: profile source tree editor` + +Review: +- 初回 external review は request_changes。 +- first follow-up review も request_changes(source-root symlink blocker)。 +- second follow-up review は approve。merge-blocking issue なし。 + +Final validation in Orchestrator worktree: +- `git diff --check`: pass +- `cargo test -p yoi-workspace-server`: pass(94 lib tests + 2 main tests) +- `cargo test -p worker-runtime --features ws-server,fs-store`: pass(47 lib tests + 5 main tests + doc tests) +- `cargo check -p yoi`: pass +- `cd web/workspace && deno task check`: pass(0 errors / 0 warnings) +- `cd web/workspace && deno task test`: pass(19 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 済み。 +- Reviewer の non-blocking follow-up: `read_profile_tree_file` に対する root-symlink direct assertion を追加すると regression matrix がさらに明示的になるが、同じ guarded `checked_source_path` を通るため merge blocker ではない。 + +---