diff --git a/.yoi/tickets/00001KWZ5KERY/item.md b/.yoi/tickets/00001KWZ5KERY/item.md index 5000009f..dd93428b 100644 --- a/.yoi/tickets/00001KWZ5KERY/item.md +++ b/.yoi/tickets/00001KWZ5KERY/item.md @@ -2,7 +2,7 @@ title: 'Migrate Profiles to Decodal ProfileSourceArchive for Runtime launch' state: 'inprogress' created_at: '2026-07-07T20:51:35Z' -updated_at: '2026-07-08T09:13:16Z' +updated_at: '2026-07-08T09:57:38Z' assignee: null queued_by: 'workspace-panel' queued_at: '2026-07-08T09:11:22Z' diff --git a/.yoi/tickets/00001KWZ5KERY/thread.md b/.yoi/tickets/00001KWZ5KERY/thread.md index 9f42d2ec..d1e51a0e 100644 --- a/.yoi/tickets/00001KWZ5KERY/thread.md +++ b/.yoi/tickets/00001KWZ5KERY/thread.md @@ -140,4 +140,30 @@ Acceptance basis: - Ticket の scope は Decodal Profile DSL / ProfileSourceArchive / Runtime archive-only profile resolution / compatibility fallback isolation に明確化されており、Profile editor UI、secret sync、plugin package manager、runtime-local artifact sync full implementation は非目標として明確。 - side effect はこの `queued -> inprogress` acceptance 後に、worktree-workflow に沿って implementation worktree を作成してから開始する。 +--- + + + +## Implementation report + +Implemented Decodal ProfileSourceArchive launch path for Browser/Workspace Backend Worker creation. + +Changes: +- Added worker-runtime ProfileSourceArchive tar manifest/content model with digest verification, source limits, path confinement, import-map-only ArchiveSourceLoader, and Decodal-to-profile resolution. +- Added builtin Decodal profile sources and Backend-built archives for normal workspace and Companion launches. +- Runtime config bundle validation now verifies/caches the profile source archive and Worker spawn passes the verified bundle to execution. +- ProfileRuntimeWorkerFactory now resolves normal Runtime launches from archive-contained Decodal sources and keeps Runtime-local Lua/filesystem discovery as compatibility/debug fallback only. +- Browser/runtime summaries expose only source graph summary, not archive content/digest/store paths. +- Added tests for archive build/verify, digest mismatch, archive-only resolution, and import-map rejection. + +Validation performed: +- git diff --check HEAD +- cargo test -p worker-runtime --features ws-server,fs-store +- 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/Cargo.lock b/Cargo.lock index 30b91e9f..e23dd6c8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -907,6 +907,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +[[package]] +name = "decodal" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4291c87ce887fafc0acf9f40f4bc17e111457e9d62f1b1530113be6b7a7f1a21" + [[package]] name = "deltae" version = "0.3.2" @@ -1072,7 +1078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2543,7 +2549,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3469,7 +3475,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3526,7 +3532,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4114,6 +4120,17 @@ dependencies = [ "libc", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.13.5" @@ -4136,7 +4153,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5472,7 +5489,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5914,6 +5931,7 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", + "decodal", "futures", "llm-engine", "manifest", @@ -5922,6 +5940,7 @@ dependencies = [ "serde_json", "session-store", "sha2 0.11.0", + "tar", "tempfile", "thiserror 2.0.18", "tokio", @@ -5952,6 +5971,16 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.4", +] + [[package]] name = "xml5ever" version = "0.17.0" diff --git a/Cargo.toml b/Cargo.toml index da9ab790..3b5cf63c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -86,6 +86,7 @@ yoi-workspace-server = { path = "crates/workspace-server" } # need `default-features = false`, which workspace inheritance cannot override. async-trait = "0.1" axum = "0.8" +decodal = "0.1.1" fs4 = "0.13" futures = "0.3" libc = "0.2" @@ -93,6 +94,7 @@ schemars = "1.2" serde = "1.0" serde_json = "1.0" serde_yaml = "0.9.34" +tar = "0.4" rusqlite = { version = "0.37", features = ["bundled"] } sha2 = "0.11" tempfile = "3.27" diff --git a/crates/manifest/src/lib.rs b/crates/manifest/src/lib.rs index c32644ee..cad1f620 100644 --- a/crates/manifest/src/lib.rs +++ b/crates/manifest/src/lib.rs @@ -19,7 +19,7 @@ pub use profile::{ ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot, - resolve_profile_artifact, + resolve_profile_artifact, resolve_profile_artifact_value, }; pub use protocol::{Permission, ScopeRule}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 37fd7859..88ed5d6a 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -189,6 +189,10 @@ pub enum ProfileSource { #[serde(default, skip_serializing_if = "Option::is_none")] provenance: Option, }, + Archive { + archive_id: String, + source: String, + }, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -1405,6 +1409,10 @@ fn source_name(source: &ProfileSource) -> Option { .and_then(|s| s.to_str()) .map(str::to_string), ProfileSource::Registry { name, .. } => Some(name.clone()), + ProfileSource::Archive { source, .. } => Path::new(source) + .file_stem() + .and_then(|s| s.to_str()) + .map(str::to_string), } } fn canonicalize_existing_dir(path: &Path) -> Result { @@ -1438,12 +1446,21 @@ pub fn resolve_profile_artifact( source: ProfileSource, base_dir: &Path, raw_artifact: serde_json::Value, +) -> Result { + resolve_profile_artifact_value(raw_artifact, source, base_dir, "artifact-worker") +} + +pub fn resolve_profile_artifact_value( + raw_artifact: serde_json::Value, + source: ProfileSource, + base_dir: &Path, + worker_name: &str, ) -> Result { resolve_lua_profile_value( source, base_dir, base_dir, - ProfileResolveOptions::with_worker_name("artifact-worker"), + ProfileResolveOptions::with_worker_name(worker_name), raw_artifact.clone(), raw_artifact, None, diff --git a/crates/worker-runtime/Cargo.toml b/crates/worker-runtime/Cargo.toml index 8ceab741..a010ed23 100644 --- a/crates/worker-runtime/Cargo.toml +++ b/crates/worker-runtime/Cargo.toml @@ -13,20 +13,22 @@ required-features = ["ws-server", "fs-store"] [features] default = [] -fs-store = ["dep:serde_json"] -http-server = ["dep:axum", "dep:serde_json", "dep:tower"] +fs-store = [] +http-server = ["dep:axum", "dep:tower"] ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"] [dependencies] async-trait.workspace = true axum = { workspace = true, optional = true } futures = { workspace = true, optional = true } +decodal.workspace = true manifest.workspace = true protocol.workspace = true serde = { workspace = true, features = ["derive"] } session-store.workspace = true sha2.workspace = true -serde_json = { workspace = true, optional = true } +serde_json.workspace = true +tar.workspace = true thiserror = { workspace = true } tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } tower = { workspace = true, features = ["util"], optional = true } diff --git a/crates/worker-runtime/src/config_bundle.rs b/crates/worker-runtime/src/config_bundle.rs index 18ea771a..beafad32 100644 --- a/crates/worker-runtime/src/config_bundle.rs +++ b/crates/worker-runtime/src/config_bundle.rs @@ -1,5 +1,9 @@ use crate::catalog::{ConfigBundleRef, ProfileSelector}; use crate::error::RuntimeError; +use crate::profile_archive::{ + ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef, + VerifiedProfileSourceArchive, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -18,6 +22,8 @@ pub struct ConfigBundle { pub profiles: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub declarations: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_archive: Option, } impl ConfigBundle { @@ -60,6 +66,16 @@ impl ConfigBundle { )); } + if let Some(archive) = &self.profile_source_archive { + lines.push(format!( + "profile_archive\0{}\0{}\0{}", + archive.reference.id, archive.reference.digest, archive.reference.size_bytes + )); + for (selector, path) in &archive.reference.source_graph.entrypoints { + lines.push(format!("profile_archive_entrypoint\0{selector}\0{path}")); + } + } + lines.sort(); let mut hasher = Sha256::new(); for line in lines { @@ -86,6 +102,10 @@ impl ConfigBundle { provenance: self.metadata.provenance.clone(), profile_count: self.profiles.len(), declaration_count: self.declarations.len(), + profile_source_archive: self + .profile_source_archive + .as_ref() + .map(|archive| archive.reference.source_graph.clone()), } } @@ -164,6 +184,8 @@ pub struct ConfigBundleSummary { pub provenance: ConfigBundleProvenance, pub profile_count: usize, pub declaration_count: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_archive: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -222,6 +244,16 @@ pub(crate) fn validate_config_bundle(bundle: &ConfigBundle) -> Result<(), Runtim } validate_declaration_reference(&bundle.metadata.id, declaration)?; } + + if let Some(archive) = &bundle.profile_source_archive { + validate_profile_source_archive_ref(&archive.reference).map_err(|err| { + RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}")) + })?; + archive.verify().map_err(|err| { + RuntimeError::InvalidRequest(format!("invalid profile source archive: {err}")) + })?; + } + Ok(()) } @@ -232,6 +264,34 @@ pub(crate) fn validate_config_bundle_ref(reference: &ConfigBundleRef) -> Result< Ok(()) } +pub fn verified_profile_source_archive( + bundle: &ConfigBundle, +) -> Result, ProfileArchiveError> { + bundle + .profile_source_archive + .as_ref() + .map(ProfileSourceArchive::verify) + .transpose() +} + +fn validate_profile_source_archive_ref( + reference: &ProfileSourceArchiveRef, +) -> Result<(), ProfileArchiveError> { + if reference.id.trim().is_empty() { + return Err(ProfileArchiveError::MissingEntrypoint( + "archive id".to_string(), + )); + } + if !reference.digest.starts_with("sha256:") { + return Err(ProfileArchiveError::ArchiveDigestMismatch { + id: reference.id.clone(), + expected: "sha256:".to_string(), + actual: reference.digest.clone(), + }); + } + Ok(()) +} + pub(crate) fn validate_profile_selector( selector: ProfileSelector, bundle_id: Option<&str>, @@ -468,6 +528,7 @@ mod tests { name: "credential".to_string(), reference: reference.to_string(), }], + profile_source_archive: None, } .with_computed_digest() } diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs index d31eebc2..04e45226 100644 --- a/crates/worker-runtime/src/execution.rs +++ b/crates/worker-runtime/src/execution.rs @@ -1,4 +1,5 @@ use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus}; +use crate::config_bundle::ConfigBundle; use crate::error::RuntimeError; use crate::identity::WorkerRef; use crate::interaction::WorkerInput; @@ -287,6 +288,7 @@ pub struct WorkerExecutionSpawnRequest { pub request: CreateWorkerRequest, pub context: WorkerExecutionContext, pub working_directory: Option, + pub config_bundle: Option, } /// Result of backend Worker spawn/initialization. diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index e63fc7c3..e01dc9b3 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -849,6 +849,7 @@ mod tests { label: Some("test".to_string()), }], declarations: Vec::new(), + profile_source_archive: None, } .with_computed_digest() } @@ -1135,6 +1136,7 @@ mod ws_tests { label: Some("ws".to_string()), }], declarations: Vec::new(), + profile_source_archive: None, } .with_computed_digest() } diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index 686983fb..405aab74 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -19,6 +19,7 @@ pub mod identity; pub mod interaction; pub mod management; pub mod observation; +pub mod profile_archive; mod runtime; pub mod worker_backend; pub mod working_directory; diff --git a/crates/worker-runtime/src/profile_archive.rs b/crates/worker-runtime/src/profile_archive.rs new file mode 100644 index 00000000..d0e9e9fd --- /dev/null +++ b/crates/worker-runtime/src/profile_archive.rs @@ -0,0 +1,544 @@ +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::io::{Cursor, Read, Write}; +use std::path::{Component, Path, PathBuf}; +use tar::{Archive, Builder, Header}; + +const MANIFEST_PATH: &str = "manifest.json"; +const MAX_SOURCES: usize = 64; +const MAX_SOURCE_BYTES: u64 = 256 * 1024; +const MAX_TOTAL_BYTES: u64 = 1024 * 1024; +const MAX_PATH_DEPTH: usize = 8; + +#[derive(Debug, thiserror::Error)] +pub enum ProfileArchiveError { + #[error("profile source archive has unsupported schema version {0}")] + UnsupportedSchema(u32), + #[error("profile source archive path must be relative and confined: {0}")] + UnsafePath(String), + #[error("profile source archive contains unsupported entry type at {0}")] + UnsupportedEntry(String), + #[error("profile source archive limit exceeded: {0}")] + LimitExceeded(&'static str), + #[error("profile source archive missing manifest.json")] + MissingManifest, + #[error("profile source archive missing source {0}")] + MissingSource(String), + #[error( + "profile source archive source digest mismatch for {path}: expected {expected}, got {actual}" + )] + SourceDigestMismatch { + path: String, + expected: String, + actual: String, + }, + #[error("profile source archive digest mismatch for {id}: expected {expected}, got {actual}")] + ArchiveDigestMismatch { + id: String, + expected: String, + actual: String, + }, + #[error("profile source archive has unsupported source kind {kind} at {path}")] + UnsupportedSource { path: String, kind: String }, + #[error("profile source archive has no entrypoint for selector {0}")] + MissingEntrypoint(String), + #[error("profile source archive import is not declared in manifest import map: {0}")] + ImportNotDeclared(String), + #[error("failed to read profile source archive: {0}")] + Io(String), + #[error("failed to parse profile source archive manifest: {0}")] + Json(String), + #[error("failed to evaluate Decodal profile source {path}: {message}")] + Decodal { path: String, message: String }, + #[error("failed to resolve archive profile artifact {profile_source}: {message}")] + Profile { + profile_source: String, + message: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProfileSourceArchiveRef { + pub id: String, + pub digest: String, + pub size_bytes: u64, + pub source_graph: ProfileSourceGraphSummary, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProfileSourceGraphSummary { + pub source_count: usize, + pub total_source_bytes: u64, + pub entrypoints: BTreeMap, + pub import_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProfileSourceArchive { + pub reference: ProfileSourceArchiveRef, + pub content: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProfileSourceArchiveManifest { + pub schema_version: u32, + pub id: String, + pub entrypoints: BTreeMap, + pub imports: BTreeMap, + pub sources: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProfileSourceArchiveSource { + pub path: String, + pub kind: String, + pub digest: String, + pub size_bytes: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ProfileSourceArchiveInput { + pub id: String, + pub entrypoints: BTreeMap, + pub imports: BTreeMap, + pub sources: BTreeMap, +} + +impl ProfileSourceArchive { + pub fn build(input: ProfileSourceArchiveInput) -> Result { + if input.sources.len() > MAX_SOURCES { + return Err(ProfileArchiveError::LimitExceeded("source count")); + } + let mut total_source_bytes = 0u64; + let mut source_meta = Vec::new(); + for (path, source) in &input.sources { + validate_archive_path(path)?; + let size = source.as_bytes().len() as u64; + if size > MAX_SOURCE_BYTES { + return Err(ProfileArchiveError::LimitExceeded("source bytes")); + } + total_source_bytes = total_source_bytes + .checked_add(size) + .ok_or(ProfileArchiveError::LimitExceeded("total source bytes"))?; + if total_source_bytes > MAX_TOTAL_BYTES { + return Err(ProfileArchiveError::LimitExceeded("total source bytes")); + } + source_meta.push(ProfileSourceArchiveSource { + path: path.clone(), + kind: "decodal".to_string(), + digest: sha256_hex(source.as_bytes()), + size_bytes: size, + }); + } + for path in input.entrypoints.values().chain(input.imports.values()) { + validate_archive_path(path)?; + if !input.sources.contains_key(path) { + return Err(ProfileArchiveError::MissingSource(path.clone())); + } + } + let manifest = ProfileSourceArchiveManifest { + schema_version: 1, + id: input.id.clone(), + entrypoints: input.entrypoints.clone(), + imports: input.imports.clone(), + sources: source_meta, + }; + let manifest_json = serde_json::to_vec_pretty(&manifest) + .map_err(|err| ProfileArchiveError::Json(err.to_string()))?; + let mut bytes = Vec::new(); + { + let mut builder = Builder::new(&mut bytes); + append_bytes(&mut builder, MANIFEST_PATH, &manifest_json)?; + for (path, source) in &input.sources { + append_bytes(&mut builder, path, source.as_bytes())?; + } + builder + .finish() + .map_err(|err| ProfileArchiveError::Io(err.to_string()))?; + } + let digest = sha256_hex(&bytes); + let reference = ProfileSourceArchiveRef { + id: input.id, + digest, + size_bytes: bytes.len() as u64, + source_graph: ProfileSourceGraphSummary { + source_count: input.sources.len(), + total_source_bytes, + entrypoints: input.entrypoints, + import_count: manifest.imports.len(), + }, + }; + Ok(Self { + reference, + content: bytes, + }) + } + + pub fn verify(&self) -> Result { + let digest = sha256_hex(&self.content); + if digest != self.reference.digest { + return Err(ProfileArchiveError::ArchiveDigestMismatch { + id: self.reference.id.clone(), + expected: self.reference.digest.clone(), + actual: digest, + }); + } + VerifiedProfileSourceArchive::from_bytes(self.reference.clone(), &self.content) + } +} + +#[derive(Debug, Clone)] +pub struct VerifiedProfileSourceArchive { + reference: ProfileSourceArchiveRef, + manifest: ProfileSourceArchiveManifest, + sources: BTreeMap, +} + +impl VerifiedProfileSourceArchive { + pub fn from_bytes( + reference: ProfileSourceArchiveRef, + bytes: &[u8], + ) -> Result { + if bytes.len() as u64 > MAX_TOTAL_BYTES + 64 * 1024 { + return Err(ProfileArchiveError::LimitExceeded("archive bytes")); + } + let mut archive = Archive::new(Cursor::new(bytes)); + let mut manifest: Option = None; + let mut entries = BTreeMap::new(); + for entry in archive + .entries() + .map_err(|err| ProfileArchiveError::Io(err.to_string()))? + { + let mut entry = entry.map_err(|err| ProfileArchiveError::Io(err.to_string()))?; + let path = entry + .path() + .map_err(|err| ProfileArchiveError::Io(err.to_string()))? + .to_string_lossy() + .to_string(); + validate_archive_path(&path)?; + if !entry.header().entry_type().is_file() { + return Err(ProfileArchiveError::UnsupportedEntry(path)); + } + let size = entry.size(); + if size > MAX_SOURCE_BYTES && path != MANIFEST_PATH { + return Err(ProfileArchiveError::LimitExceeded("source bytes")); + } + let mut data = Vec::new(); + entry + .read_to_end(&mut data) + .map_err(|err| ProfileArchiveError::Io(err.to_string()))?; + if path == MANIFEST_PATH { + manifest = Some( + serde_json::from_slice(&data) + .map_err(|err| ProfileArchiveError::Json(err.to_string()))?, + ); + } else { + let source = String::from_utf8(data) + .map_err(|err| ProfileArchiveError::Io(err.to_string()))?; + entries.insert(path, source); + } + if entries.len() > MAX_SOURCES { + return Err(ProfileArchiveError::LimitExceeded("source count")); + } + } + let manifest = manifest.ok_or(ProfileArchiveError::MissingManifest)?; + if manifest.schema_version != 1 { + return Err(ProfileArchiveError::UnsupportedSchema( + manifest.schema_version, + )); + } + if manifest.id != reference.id { + return Err(ProfileArchiveError::ArchiveDigestMismatch { + id: reference.id, + expected: "matching archive id".to_string(), + actual: manifest.id, + }); + } + let mut total = 0u64; + for source in &manifest.sources { + validate_archive_path(&source.path)?; + if source.kind != "decodal" { + return Err(ProfileArchiveError::UnsupportedSource { + path: source.path.clone(), + kind: source.kind.clone(), + }); + } + let content = entries + .get(&source.path) + .ok_or_else(|| ProfileArchiveError::MissingSource(source.path.clone()))?; + let digest = sha256_hex(content.as_bytes()); + if digest != source.digest { + return Err(ProfileArchiveError::SourceDigestMismatch { + path: source.path.clone(), + expected: source.digest.clone(), + actual: digest, + }); + } + let size = content.as_bytes().len() as u64; + if size != source.size_bytes { + return Err(ProfileArchiveError::LimitExceeded( + "source byte metadata mismatch", + )); + } + total += size; + } + if total != reference.source_graph.total_source_bytes + || manifest.sources.len() != reference.source_graph.source_count + { + return Err(ProfileArchiveError::LimitExceeded( + "source graph metadata mismatch", + )); + } + for path in manifest + .entrypoints + .values() + .chain(manifest.imports.values()) + { + validate_archive_path(path)?; + if !entries.contains_key(path) { + return Err(ProfileArchiveError::MissingSource(path.clone())); + } + } + Ok(Self { + reference, + manifest, + sources: entries, + }) + } + + pub fn reference(&self) -> &ProfileSourceArchiveRef { + &self.reference + } + + pub fn resolve_profile( + &self, + selector: &str, + worker_root: &Path, + worker_name: &str, + ) -> Result { + let path = self + .manifest + .entrypoints + .get(selector) + .or_else(|| self.manifest.entrypoints.get("default")) + .ok_or_else(|| ProfileArchiveError::MissingEntrypoint(selector.to_string()))?; + let mut engine = Engine::new(ArchiveSourceLoader { archive: self }); + let source = self + .sources + .get(path) + .ok_or_else(|| ProfileArchiveError::MissingSource(path.clone()))?; + let module = engine.add_root_source(path, path, source).map_err(|err| { + ProfileArchiveError::Decodal { + path: path.clone(), + message: format!("{err:?}"), + } + })?; + let value = engine + .eval_module(module) + .map_err(|err| ProfileArchiveError::Decodal { + path: path.clone(), + message: format!("{err:?}"), + })?; + let data = engine + .materialize(&value) + .map_err(|err| ProfileArchiveError::Decodal { + path: path.clone(), + message: format!("{err:?}"), + })?; + let json = decodal_data_to_json(&data); + let resolved = resolve_profile_artifact_value( + json, + ProfileSource::Archive { + archive_id: self.reference.id.clone(), + source: path.clone(), + }, + worker_root, + worker_name, + ) + .map_err(|err| ProfileArchiveError::Profile { + profile_source: path.clone(), + message: err.to_string(), + })?; + Ok(resolved.manifest) + } +} + +pub struct ArchiveSourceLoader<'a> { + archive: &'a VerifiedProfileSourceArchive, +} + +impl<'a> ArchiveSourceLoader<'a> { + pub fn new(archive: &'a VerifiedProfileSourceArchive) -> Self { + Self { archive } + } +} + +impl SourceLoader for ArchiveSourceLoader<'_> { + fn load( + &mut self, + _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(|| { + decodal::Diagnostic::new( + decodal::DiagnosticKind::Import, + decodal::Span::default(), + format!("archive source missing: {path}"), + ) + })?; + Ok(LoadedSource { + key: path.clone(), + name: path.clone(), + source: source.clone(), + }) + } +} + +fn decodal_data_to_json(data: &decodal::Data) -> serde_json::Value { + match data { + decodal::Data::Bool(value) => serde_json::Value::Bool(*value), + decodal::Data::Int(value) => serde_json::Value::Number(serde_json::Number::from(*value)), + decodal::Data::Float(value) => serde_json::Number::from_f64(*value) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + decodal::Data::String(value) => serde_json::Value::String(value.clone()), + decodal::Data::Array(values) => { + serde_json::Value::Array(values.iter().map(decodal_data_to_json).collect()) + } + decodal::Data::Object(fields) => serde_json::Value::Object( + fields + .iter() + .map(|field| (field.name.clone(), decodal_data_to_json(&field.value))) + .collect(), + ), + } +} + +fn append_bytes( + builder: &mut Builder, + path: &str, + data: &[u8], +) -> Result<(), ProfileArchiveError> { + validate_archive_path(path)?; + let mut header = Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder + .append_data(&mut header, path, Cursor::new(data)) + .map_err(|err| ProfileArchiveError::Io(err.to_string())) +} + +fn validate_archive_path(path: &str) -> Result<(), ProfileArchiveError> { + if path.is_empty() || path == MANIFEST_PATH { + return if path == MANIFEST_PATH { + Ok(()) + } else { + Err(ProfileArchiveError::UnsafePath(path.to_string())) + }; + } + let path_ref = Path::new(path); + if path_ref.is_absolute() { + return Err(ProfileArchiveError::UnsafePath(path.to_string())); + } + let mut depth = 0usize; + for component in path_ref.components() { + match component { + Component::Normal(_) => depth += 1, + _ => return Err(ProfileArchiveError::UnsafePath(path.to_string())), + } + } + if depth == 0 || depth > MAX_PATH_DEPTH { + return Err(ProfileArchiveError::UnsafePath(path.to_string())); + } + let normalized = PathBuf::from(path).to_string_lossy().replace('\\', "/"); + if normalized != path { + return Err(ProfileArchiveError::UnsafePath(path.to_string())); + } + Ok(()) +} + +fn sha256_hex(data: &[u8]) -> String { + let digest = Sha256::digest(data); + let mut out = String::from("sha256:"); + for byte in digest.as_slice() { + use std::fmt::Write as _; + let _ = write!(out, "{byte:02x}"); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_archive() -> ProfileSourceArchive { + let mut entrypoints = BTreeMap::new(); + entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string()); + let mut sources = BTreeMap::new(); + sources.insert( + "profiles/default.dcdl".to_string(), + r#"{ + slug = "default"; + description = "Default"; + scope = "workspace_read"; + }"# + .to_string(), + ); + ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: "builtin".to_string(), + entrypoints, + imports: BTreeMap::new(), + sources, + }) + .unwrap() + } + + #[test] + fn build_and_verify_archive() { + let archive = sample_archive(); + let verified = archive.verify().unwrap(); + assert_eq!(verified.reference().source_graph.source_count, 1); + } + + #[test] + fn rejects_digest_mismatch() { + let mut archive = sample_archive(); + archive.reference.digest = "sha256:bad".to_string(); + assert!(archive.verify().is_err()); + } + + #[test] + fn resolves_profile_from_archive_without_filesystem_loader() { + let archive = sample_archive(); + let verified = archive.verify().unwrap(); + let root = tempfile::tempdir().unwrap(); + let manifest = verified + .resolve_profile("default", root.path(), "archive-worker") + .unwrap(); + assert_eq!(manifest.worker.name, "archive-worker"); + } + + #[test] + fn archive_loader_rejects_undeclared_import() { + let archive = sample_archive(); + let verified = archive.verify().unwrap(); + let mut loader = ArchiveSourceLoader::new(&verified); + assert!(loader.load(None, "missing").is_err()); + } +} diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 44ef2309..8665d6f3 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -244,6 +244,13 @@ impl Runtime { state.ensure_running()?; validate_create_worker_request(&request)?; state.validate_worker_config_boundary(&request)?; + let config_bundle = state + .config_bundles + .get(&request.config_bundle.id) + .cloned() + .ok_or_else(|| RuntimeError::ConfigBundleMissing { + bundle_id: request.config_bundle.id.clone(), + })?; let backend = state.execution_backend.clone().ok_or_else(|| { RuntimeError::ExecutionBackendUnavailable { message: "worker creation requires an execution backend".to_string(), @@ -289,6 +296,7 @@ impl Runtime { request, context: self.execution_context(worker_ref.clone()), working_directory: None, + config_bundle: Some(config_bundle), }; (backend, worker_ref, spawn_request) }; @@ -1619,6 +1627,7 @@ mod tests { name: "read".to_string(), reference: "capability:read".to_string(), }], + profile_source_archive: None, } .with_computed_digest() } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index d6c9d357..a5e62d4e 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -14,6 +14,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, mpsc}; use std::time::Duration; +use crate::config_bundle::verified_profile_source_archive; use crate::execution::{ WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult, @@ -171,14 +172,34 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { .as_ref() .map(|binding| binding.cwd().to_path_buf()) .unwrap_or_else(|| self.cwd.clone()); - // Profile discovery uses the runtime profile base, not the Worker working directory. - // The working directory may be an arbitrary repository checkout that intentionally - // does not carry Yoi profile files. - let (mut manifest, loader) = worker::entrypoint::resolve_runtime_profile_manifest( - profile.as_deref(), - &self.profile_base_dir, - &worker_name, - )?; + 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 manifest = archive + .resolve_profile(selector, &worker_root, &worker_name) + .map_err(|err| format!("failed to resolve profile source archive: {err}"))?; + worker::entrypoint::resolve_runtime_profile_manifest_from_manifest( + manifest, + &worker_root, + &worker_name, + )? + } else { + // Compatibility/debug fallback for direct CLI tests. Normal Browser/Backend launch + // supplies a ProfileSourceArchive inside the Runtime config bundle and must not use + // Runtime-local filesystem profile discovery. + worker::entrypoint::resolve_runtime_profile_manifest( + profile.as_deref(), + &self.profile_base_dir, + &worker_name, + )? + }; manifest.worker.name = worker_name; let store_dir = self.store_dir()?; @@ -769,6 +790,7 @@ mod tests { label: Some("adapter-test".to_string()), }], declarations: Vec::new(), + profile_source_archive: None, } .with_computed_digest() } diff --git a/crates/worker/src/entrypoint.rs b/crates/worker/src/entrypoint.rs index 4e41bb81..346333b2 100644 --- a/crates/worker/src/entrypoint.rs +++ b/crates/worker/src/entrypoint.rs @@ -239,6 +239,19 @@ pub fn resolve_runtime_profile_manifest( Ok((manifest, loader)) } +pub fn resolve_runtime_profile_manifest_from_manifest( + mut manifest: WorkerManifest, + workspace_root: &Path, + worker_name: &str, +) -> Result<(WorkerManifest, PromptLoader), String> { + if manifest.worker.name.is_empty() { + manifest.worker.name = worker_name.to_string(); + } + apply_profile_launch_policy(&mut manifest, workspace_root, None)?; + apply_plugin_resolution_plan(&mut manifest, workspace_root); + Ok((manifest, PromptLoader::builtins_only())) +} + fn load_single_manifest( path: &Path, explicit_worker_name: Option<&str>, diff --git a/crates/workspace-server/src/companion.rs b/crates/workspace-server/src/companion.rs index 50e9d721..27a00ed4 100644 --- a/crates/workspace-server/src/companion.rs +++ b/crates/workspace-server/src/companion.rs @@ -1,3 +1,4 @@ +use std::collections::BTreeMap; use std::sync::{Arc, Mutex}; use chrono::Utc; @@ -6,6 +7,7 @@ use worker_runtime::catalog::ProfileSelector; use worker_runtime::config_bundle::{ ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, }; +use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; use crate::hosts::{ DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest, @@ -452,10 +454,32 @@ fn companion_config_bundle() -> ConfigBundle { label: Some("Workspace Companion".to_string()), }], declarations: Vec::new(), + profile_source_archive: Some(companion_profile_archive()), } .with_computed_digest() } +fn companion_profile_archive() -> ProfileSourceArchive { + let mut entrypoints = BTreeMap::new(); + entrypoints.insert("default".to_string(), "profiles/companion.dcdl".to_string()); + entrypoints.insert( + COMPANION_PROFILE_ID.to_string(), + "profiles/companion.dcdl".to_string(), + ); + let mut sources = BTreeMap::new(); + sources.insert( + "profiles/companion.dcdl".to_string(), + include_str!("../../../resources/profiles/companion.dcdl").to_string(), + ); + ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: "workspace-companion-profile-archive-v1".to_string(), + entrypoints, + imports: BTreeMap::new(), + sources, + }) + .expect("builtin Companion Decodal profile source archive is valid") +} + fn companion_state_for_worker(worker: &WorkerSummary) -> CompanionState { if !worker.capabilities.can_accept_input { return CompanionState::Error; diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index b01f863a..9d0ea8d9 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -7,6 +7,7 @@ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::{ + collections::BTreeMap, path::PathBuf, sync::{Arc, RwLock}, time::Duration, @@ -41,6 +42,7 @@ use worker_runtime::management::{RuntimeOptions as EmbeddedRuntimeOptions, Runti use worker_runtime::observation::{ TranscriptProjection as EmbeddedTranscriptProjection, TranscriptQuery, TranscriptRole, }; +use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput}; const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime"; const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host"; @@ -2371,10 +2373,75 @@ fn default_embedded_config_bundle(profile: &ProfileSelector) -> ConfigBundle { label: embedded_profile_label(profile), }], declarations: Vec::new(), + profile_source_archive: Some(default_profile_source_archive(profile)), } .with_computed_digest() } +fn default_profile_source_archive(profile: &ProfileSelector) -> ProfileSourceArchive { + let selected = embedded_profile_label(profile).unwrap_or_else(|| "default".to_string()); + let mut entrypoints = BTreeMap::new(); + entrypoints.insert("default".to_string(), "profiles/default.dcdl".to_string()); + entrypoints.insert( + "builtin:default".to_string(), + "profiles/default.dcdl".to_string(), + ); + for slug in ["companion", "intake", "orchestrator", "coder", "reviewer"] { + entrypoints.insert(format!("builtin:{slug}"), format!("profiles/{slug}.dcdl")); + } + entrypoints.insert(selected, embedded_profile_path(profile)); + + let mut sources = BTreeMap::new(); + sources.insert( + "profiles/default.dcdl".to_string(), + include_str!("../../../resources/profiles/default.dcdl").to_string(), + ); + sources.insert( + "profiles/companion.dcdl".to_string(), + include_str!("../../../resources/profiles/companion.dcdl").to_string(), + ); + sources.insert( + "profiles/intake.dcdl".to_string(), + include_str!("../../../resources/profiles/intake.dcdl").to_string(), + ); + sources.insert( + "profiles/orchestrator.dcdl".to_string(), + include_str!("../../../resources/profiles/orchestrator.dcdl").to_string(), + ); + sources.insert( + "profiles/coder.dcdl".to_string(), + include_str!("../../../resources/profiles/coder.dcdl").to_string(), + ); + sources.insert( + "profiles/reviewer.dcdl".to_string(), + include_str!("../../../resources/profiles/reviewer.dcdl").to_string(), + ); + + ProfileSourceArchive::build(ProfileSourceArchiveInput { + id: "builtin-decodal-profiles-v1".to_string(), + entrypoints, + imports: BTreeMap::new(), + sources, + }) + .expect("builtin Decodal profile source archive is valid") +} + +fn embedded_profile_path(profile: &ProfileSelector) -> String { + match profile { + ProfileSelector::RuntimeDefault => "profiles/default.dcdl".to_string(), + ProfileSelector::Builtin(name) | ProfileSelector::Named(name) => { + match name.strip_prefix("builtin:").unwrap_or(name) { + "companion" => "profiles/companion.dcdl".to_string(), + "intake" => "profiles/intake.dcdl".to_string(), + "orchestrator" => "profiles/orchestrator.dcdl".to_string(), + "coder" => "profiles/coder.dcdl".to_string(), + "reviewer" => "profiles/reviewer.dcdl".to_string(), + _ => "profiles/default.dcdl".to_string(), + } + } + } +} + fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector { match intent { WorkerSpawnIntent::TicketRole { role, .. } => { @@ -2900,6 +2967,7 @@ mod tests { name: "read".to_string(), reference: "capability:read".to_string(), }], + profile_source_archive: None, } .with_computed_digest() } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d42242d2..b0f1ec80 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3405,6 +3405,7 @@ mod tests { label: Some("server-test".to_string()), }], declarations: Vec::new(), + profile_source_archive: None, } .with_computed_digest() } diff --git a/package.nix b/package.nix index 3f93721e..0b196b78 100644 --- a/package.nix +++ b/package.nix @@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec { filter = sourceFilter; }; - cargoHash = "sha256-ICEIo7wR65s6CQCzCVa1KaEou7fpnaUk64zwokb92Fc="; + cargoHash = "sha256-g9Bh9qQ1fGZtzsjO2/vc4jZdrH5YoCR0rOgs//xUDrU="; depsExtraArgs = { # Older fetchCargoVendor utilities used crates.io's API download endpoint, diff --git a/resources/profiles/coder.dcdl b/resources/profiles/coder.dcdl new file mode 100644 index 00000000..bdd296a0 --- /dev/null +++ b/resources/profiles/coder.dcdl @@ -0,0 +1,13 @@ +slug = "coder"; +description = "Ticket implementation coder profile."; +scope = "workspace_write"; + +tool_enabled = { + bash = true; + read = true; + write = true; + edit = true; + grep = true; + glob = true; + memory = true; +}; diff --git a/resources/profiles/companion.dcdl b/resources/profiles/companion.dcdl new file mode 100644 index 00000000..21951a82 --- /dev/null +++ b/resources/profiles/companion.dcdl @@ -0,0 +1,15 @@ +slug = "companion"; +description = "Workspace companion profile."; +scope = "workspace_write"; + +tool_enabled = { + bash = true; + read = true; + write = true; + edit = true; + grep = true; + glob = true; + web_search = true; + web_fetch = true; + memory = true; +}; diff --git a/resources/profiles/default.dcdl b/resources/profiles/default.dcdl new file mode 100644 index 00000000..dac27d15 --- /dev/null +++ b/resources/profiles/default.dcdl @@ -0,0 +1,21 @@ +slug = "default"; +description = "General Yoi companion profile."; +scope = "workspace_write"; + +model = { + scheme = "codex"; + model_id = "gpt-5"; + auth = { kind = "codex_oauth"; }; +}; + +tool_enabled = { + bash = true; + read = true; + write = true; + edit = true; + grep = true; + glob = true; + web_search = true; + web_fetch = true; + memory = true; +}; diff --git a/resources/profiles/intake.dcdl b/resources/profiles/intake.dcdl new file mode 100644 index 00000000..59ef69e3 --- /dev/null +++ b/resources/profiles/intake.dcdl @@ -0,0 +1,13 @@ +slug = "intake"; +description = "Ticket intake profile."; +scope = "workspace_write"; + +tool_enabled = { + bash = true; + read = true; + write = true; + edit = true; + grep = true; + glob = true; + memory = true; +}; diff --git a/resources/profiles/orchestrator.dcdl b/resources/profiles/orchestrator.dcdl new file mode 100644 index 00000000..cc68e62f --- /dev/null +++ b/resources/profiles/orchestrator.dcdl @@ -0,0 +1,13 @@ +slug = "orchestrator"; +description = "Ticket orchestrator profile."; +scope = "workspace_write"; + +tool_enabled = { + bash = true; + read = true; + write = true; + edit = true; + grep = true; + glob = true; + memory = true; +}; diff --git a/resources/profiles/reviewer.dcdl b/resources/profiles/reviewer.dcdl new file mode 100644 index 00000000..8de04f17 --- /dev/null +++ b/resources/profiles/reviewer.dcdl @@ -0,0 +1,11 @@ +slug = "reviewer"; +description = "Ticket review profile."; +scope = "workspace_read"; + +tool_enabled = { + bash = true; + read = true; + grep = true; + glob = true; + memory = true; +};