merge: decodal profile archives

# Conflicts:
#	.yoi/tickets/00001KWZ5KERY/item.md
#	.yoi/tickets/00001KWZ5KERY/thread.md
This commit is contained in:
Keisuke Hirata 2026-07-08 19:33:39 +09:00
commit 0334c5725e
No known key found for this signature in database
23 changed files with 996 additions and 29 deletions

41
Cargo.lock generated
View File

@ -907,6 +907,12 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
[[package]]
name = "decodal"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4291c87ce887fafc0acf9f40f4bc17e111457e9d62f1b1530113be6b7a7f1a21"
[[package]] [[package]]
name = "deltae" name = "deltae"
version = "0.3.2" version = "0.3.2"
@ -1072,7 +1078,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -2543,7 +2549,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -3469,7 +3475,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys 0.12.1", "linux-raw-sys 0.12.1",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -3526,7 +3532,7 @@ dependencies = [
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"webpki-root-certs", "webpki-root-certs",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -4114,6 +4120,17 @@ dependencies = [
"libc", "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]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.13.5" version = "0.13.5"
@ -4136,7 +4153,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -5472,7 +5489,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@ -5914,6 +5931,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"decodal",
"futures", "futures",
"llm-engine", "llm-engine",
"manifest", "manifest",
@ -5922,6 +5940,7 @@ dependencies = [
"serde_json", "serde_json",
"session-store", "session-store",
"sha2 0.11.0", "sha2 0.11.0",
"tar",
"tempfile", "tempfile",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
@ -5952,6 +5971,16 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" 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]] [[package]]
name = "xml5ever" name = "xml5ever"
version = "0.17.0" version = "0.17.0"

View File

@ -86,6 +86,7 @@ yoi-workspace-server = { path = "crates/workspace-server" }
# need `default-features = false`, which workspace inheritance cannot override. # need `default-features = false`, which workspace inheritance cannot override.
async-trait = "0.1" async-trait = "0.1"
axum = "0.8" axum = "0.8"
decodal = "0.1.1"
fs4 = "0.13" fs4 = "0.13"
futures = "0.3" futures = "0.3"
libc = "0.2" libc = "0.2"
@ -93,6 +94,7 @@ schemars = "1.2"
serde = "1.0" serde = "1.0"
serde_json = "1.0" serde_json = "1.0"
serde_yaml = "0.9.34" serde_yaml = "0.9.34"
tar = "0.4"
rusqlite = { version = "0.37", features = ["bundled"] } rusqlite = { version = "0.37", features = ["bundled"] }
sha2 = "0.11" sha2 = "0.11"
tempfile = "3.27" tempfile = "3.27"

View File

@ -19,7 +19,7 @@ pub use profile::{
ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry, ProfileDiscovery, ProfileError, ProfileManifestSnapshot, ProfileMetadata, ProfileRegistry,
ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver, ProfileRegistryEntry, ProfileRegistrySource, ProfileResolveOptions, ProfileResolver,
ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot, ProfileSelector, ProfileSource, ResolvedProfile, WorkspaceOverrideSnapshot,
resolve_profile_artifact, resolve_profile_artifact, resolve_profile_artifact_value,
}; };
pub use protocol::{Permission, ScopeRule}; pub use protocol::{Permission, ScopeRule};
pub use scope::{DelegationScope, Scope, ScopeError, SharedScope}; pub use scope::{DelegationScope, Scope, ScopeError, SharedScope};

View File

@ -189,6 +189,10 @@ pub enum ProfileSource {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<String>, provenance: Option<String>,
}, },
Archive {
archive_id: String,
source: String,
},
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@ -1405,6 +1409,10 @@ fn source_name(source: &ProfileSource) -> Option<String> {
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
.map(str::to_string), .map(str::to_string),
ProfileSource::Registry { name, .. } => Some(name.clone()), 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<PathBuf, ProfileError> { fn canonicalize_existing_dir(path: &Path) -> Result<PathBuf, ProfileError> {
@ -1438,12 +1446,21 @@ pub fn resolve_profile_artifact(
source: ProfileSource, source: ProfileSource,
base_dir: &Path, base_dir: &Path,
raw_artifact: serde_json::Value, raw_artifact: serde_json::Value,
) -> Result<ResolvedProfile, ProfileError> {
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<ResolvedProfile, ProfileError> { ) -> Result<ResolvedProfile, ProfileError> {
resolve_lua_profile_value( resolve_lua_profile_value(
source, source,
base_dir, base_dir,
base_dir, base_dir,
ProfileResolveOptions::with_worker_name("artifact-worker"), ProfileResolveOptions::with_worker_name(worker_name),
raw_artifact.clone(), raw_artifact.clone(),
raw_artifact, raw_artifact,
None, None,

View File

@ -13,20 +13,22 @@ required-features = ["ws-server", "fs-store"]
[features] [features]
default = [] default = []
fs-store = ["dep:serde_json"] fs-store = []
http-server = ["dep:axum", "dep:serde_json", "dep:tower"] http-server = ["dep:axum", "dep:tower"]
ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"] ws-server = ["http-server", "axum/ws", "dep:futures", "tokio/sync"]
[dependencies] [dependencies]
async-trait.workspace = true async-trait.workspace = true
axum = { workspace = true, optional = true } axum = { workspace = true, optional = true }
futures = { workspace = true, optional = true } futures = { workspace = true, optional = true }
decodal.workspace = true
manifest.workspace = true manifest.workspace = true
protocol.workspace = true protocol.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }
session-store.workspace = true session-store.workspace = true
sha2.workspace = true sha2.workspace = true
serde_json = { workspace = true, optional = true } serde_json.workspace = true
tar.workspace = true
thiserror = { workspace = true } thiserror = { workspace = true }
tokio = { workspace = true, features = ["net", "rt", "sync", "time"] } tokio = { workspace = true, features = ["net", "rt", "sync", "time"] }
tower = { workspace = true, features = ["util"], optional = true } tower = { workspace = true, features = ["util"], optional = true }

View File

@ -1,5 +1,9 @@
use crate::catalog::{ConfigBundleRef, ProfileSelector}; use crate::catalog::{ConfigBundleRef, ProfileSelector};
use crate::error::RuntimeError; use crate::error::RuntimeError;
use crate::profile_archive::{
ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef,
VerifiedProfileSourceArchive,
};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@ -18,6 +22,8 @@ pub struct ConfigBundle {
pub profiles: Vec<ConfigProfileDescriptor>, pub profiles: Vec<ConfigProfileDescriptor>,
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub declarations: Vec<ConfigDeclaration>, pub declarations: Vec<ConfigDeclaration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_source_archive: Option<ProfileSourceArchive>,
} }
impl ConfigBundle { 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(); lines.sort();
let mut hasher = Sha256::new(); let mut hasher = Sha256::new();
for line in lines { for line in lines {
@ -86,6 +102,10 @@ impl ConfigBundle {
provenance: self.metadata.provenance.clone(), provenance: self.metadata.provenance.clone(),
profile_count: self.profiles.len(), profile_count: self.profiles.len(),
declaration_count: self.declarations.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 provenance: ConfigBundleProvenance,
pub profile_count: usize, pub profile_count: usize,
pub declaration_count: usize, pub declaration_count: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_source_archive: Option<crate::profile_archive::ProfileSourceGraphSummary>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[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)?; 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(()) Ok(())
} }
@ -232,6 +264,34 @@ pub(crate) fn validate_config_bundle_ref(reference: &ConfigBundleRef) -> Result<
Ok(()) Ok(())
} }
pub fn verified_profile_source_archive(
bundle: &ConfigBundle,
) -> Result<Option<VerifiedProfileSourceArchive>, 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:<hex>".to_string(),
actual: reference.digest.clone(),
});
}
Ok(())
}
pub(crate) fn validate_profile_selector( pub(crate) fn validate_profile_selector(
selector: ProfileSelector, selector: ProfileSelector,
bundle_id: Option<&str>, bundle_id: Option<&str>,
@ -468,6 +528,7 @@ mod tests {
name: "credential".to_string(), name: "credential".to_string(),
reference: reference.to_string(), reference: reference.to_string(),
}], }],
profile_source_archive: None,
} }
.with_computed_digest() .with_computed_digest()
} }

View File

@ -1,4 +1,5 @@
use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus}; use crate::catalog::{CreateWorkerRequest, WorkingDirectoryStatus};
use crate::config_bundle::ConfigBundle;
use crate::error::RuntimeError; use crate::error::RuntimeError;
use crate::identity::WorkerRef; use crate::identity::WorkerRef;
use crate::interaction::WorkerInput; use crate::interaction::WorkerInput;
@ -287,6 +288,7 @@ pub struct WorkerExecutionSpawnRequest {
pub request: CreateWorkerRequest, pub request: CreateWorkerRequest,
pub context: WorkerExecutionContext, pub context: WorkerExecutionContext,
pub working_directory: Option<WorkingDirectoryBinding>, pub working_directory: Option<WorkingDirectoryBinding>,
pub config_bundle: Option<ConfigBundle>,
} }
/// Result of backend Worker spawn/initialization. /// Result of backend Worker spawn/initialization.

View File

@ -849,6 +849,7 @@ mod tests {
label: Some("test".to_string()), label: Some("test".to_string()),
}], }],
declarations: Vec::new(), declarations: Vec::new(),
profile_source_archive: None,
} }
.with_computed_digest() .with_computed_digest()
} }
@ -1135,6 +1136,7 @@ mod ws_tests {
label: Some("ws".to_string()), label: Some("ws".to_string()),
}], }],
declarations: Vec::new(), declarations: Vec::new(),
profile_source_archive: None,
} }
.with_computed_digest() .with_computed_digest()
} }

View File

@ -19,6 +19,7 @@ pub mod identity;
pub mod interaction; pub mod interaction;
pub mod management; pub mod management;
pub mod observation; pub mod observation;
pub mod profile_archive;
mod runtime; mod runtime;
pub mod worker_backend; pub mod worker_backend;
pub mod working_directory; pub mod working_directory;

View File

@ -0,0 +1,554 @@
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<String, String>,
pub import_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileSourceArchive {
pub reference: ProfileSourceArchiveRef,
pub content: Vec<u8>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ProfileSourceArchiveManifest {
pub schema_version: u32,
pub id: String,
pub entrypoints: BTreeMap<String, String>,
pub imports: BTreeMap<String, String>,
pub sources: Vec<ProfileSourceArchiveSource>,
}
#[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<String, String>,
pub imports: BTreeMap<String, String>,
pub sources: BTreeMap<String, String>,
}
impl ProfileSourceArchive {
pub fn build(input: ProfileSourceArchiveInput) -> Result<Self, ProfileArchiveError> {
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<VerifiedProfileSourceArchive, ProfileArchiveError> {
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<String, String>,
}
impl VerifiedProfileSourceArchive {
pub fn from_bytes(
reference: ProfileSourceArchiveRef,
bytes: &[u8],
) -> Result<Self, ProfileArchiveError> {
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<ProfileSourceArchiveManifest> = 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<WorkerManifest, ProfileArchiveError> {
let path = self
.manifest
.entrypoints
.get(selector)
.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<LoadedSource> {
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<W: Write>(
builder: &mut Builder<W>,
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 unknown_selector_does_not_fallback_to_default() {
let archive = sample_archive();
let verified = archive.verify().unwrap();
let root = tempfile::tempdir().unwrap();
assert!(matches!(
verified.resolve_profile("missing", root.path(), "archive-worker"),
Err(ProfileArchiveError::MissingEntrypoint(selector)) if selector == "missing"
));
}
#[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());
}
}

View File

@ -244,6 +244,13 @@ impl Runtime {
state.ensure_running()?; state.ensure_running()?;
validate_create_worker_request(&request)?; validate_create_worker_request(&request)?;
state.validate_worker_config_boundary(&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(|| { let backend = state.execution_backend.clone().ok_or_else(|| {
RuntimeError::ExecutionBackendUnavailable { RuntimeError::ExecutionBackendUnavailable {
message: "worker creation requires an execution backend".to_string(), message: "worker creation requires an execution backend".to_string(),
@ -289,6 +296,7 @@ impl Runtime {
request, request,
context: self.execution_context(worker_ref.clone()), context: self.execution_context(worker_ref.clone()),
working_directory: None, working_directory: None,
config_bundle: Some(config_bundle),
}; };
(backend, worker_ref, spawn_request) (backend, worker_ref, spawn_request)
}; };
@ -1619,6 +1627,7 @@ mod tests {
name: "read".to_string(), name: "read".to_string(),
reference: "capability:read".to_string(), reference: "capability:read".to_string(),
}], }],
profile_source_archive: None,
} }
.with_computed_digest() .with_computed_digest()
} }

View File

@ -14,6 +14,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, mpsc}; use std::sync::{Arc, Mutex, mpsc};
use std::time::Duration; use std::time::Duration;
use crate::config_bundle::verified_profile_source_archive;
use crate::execution::{ use crate::execution::{
WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionBackend, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult,
WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult, WorkerExecutionRunState, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult,
@ -171,14 +172,34 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory {
.as_ref() .as_ref()
.map(|binding| binding.cwd().to_path_buf()) .map(|binding| binding.cwd().to_path_buf())
.unwrap_or_else(|| self.cwd.clone()); .unwrap_or_else(|| self.cwd.clone());
// Profile discovery uses the runtime profile base, not the Worker working directory. let (mut manifest, loader) = if let Some(bundle) = request.config_bundle.as_ref() {
// The working directory may be an arbitrary repository checkout that intentionally let selector = profile.as_deref().unwrap_or("builtin:default");
// does not carry Yoi profile files. let archive = verified_profile_source_archive(bundle)
let (mut manifest, loader) = worker::entrypoint::resolve_runtime_profile_manifest( .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(), profile.as_deref(),
&self.profile_base_dir, &self.profile_base_dir,
&worker_name, &worker_name,
)?; )?
};
manifest.worker.name = worker_name; manifest.worker.name = worker_name;
let store_dir = self.store_dir()?; let store_dir = self.store_dir()?;
@ -769,6 +790,7 @@ mod tests {
label: Some("adapter-test".to_string()), label: Some("adapter-test".to_string()),
}], }],
declarations: Vec::new(), declarations: Vec::new(),
profile_source_archive: None,
} }
.with_computed_digest() .with_computed_digest()
} }

View File

@ -239,6 +239,19 @@ pub fn resolve_runtime_profile_manifest(
Ok((manifest, loader)) 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( fn load_single_manifest(
path: &Path, path: &Path,
explicit_worker_name: Option<&str>, explicit_worker_name: Option<&str>,

View File

@ -1,3 +1,4 @@
use std::collections::BTreeMap;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use chrono::Utc; use chrono::Utc;
@ -6,6 +7,7 @@ use worker_runtime::catalog::ProfileSelector;
use worker_runtime::config_bundle::{ use worker_runtime::config_bundle::{
ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor, ConfigBundle, ConfigBundleMetadata, ConfigBundleProvenance, ConfigProfileDescriptor,
}; };
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
use crate::hosts::{ use crate::hosts::{
DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest, DiagnosticSeverity, RuntimeDiagnostic, RuntimeRegistry, WorkerInputKind, WorkerInputRequest,
@ -452,10 +454,32 @@ fn companion_config_bundle() -> ConfigBundle {
label: Some("Workspace Companion".to_string()), label: Some("Workspace Companion".to_string()),
}], }],
declarations: Vec::new(), declarations: Vec::new(),
profile_source_archive: Some(companion_profile_archive()),
} }
.with_computed_digest() .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 { fn companion_state_for_worker(worker: &WorkerSummary) -> CompanionState {
if !worker.capabilities.can_accept_input { if !worker.capabilities.can_accept_input {
return CompanionState::Error; return CompanionState::Error;

View File

@ -7,6 +7,7 @@ use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use std::{ use std::{
collections::BTreeMap,
path::PathBuf, path::PathBuf,
sync::{Arc, RwLock}, sync::{Arc, RwLock},
time::Duration, time::Duration,
@ -41,6 +42,7 @@ use worker_runtime::management::{RuntimeOptions as EmbeddedRuntimeOptions, Runti
use worker_runtime::observation::{ use worker_runtime::observation::{
TranscriptProjection as EmbeddedTranscriptProjection, TranscriptQuery, TranscriptRole, TranscriptProjection as EmbeddedTranscriptProjection, TranscriptQuery, TranscriptRole,
}; };
use worker_runtime::profile_archive::{ProfileSourceArchive, ProfileSourceArchiveInput};
const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime"; const EMBEDDED_RUNTIME_ID: &str = "embedded-worker-runtime";
const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host"; const EMBEDDED_HOST_KIND: &str = "embedded-worker-runtime-host";
@ -1337,13 +1339,18 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
.profile .profile
.clone() .clone()
.unwrap_or_else(|| embedded_profile_selector(&request.intent)); .unwrap_or_else(|| embedded_profile_selector(&request.intent));
let config_bundle = match self let config_bundle = match default_embedded_config_bundle(&profile).and_then(|bundle| {
.runtime self.runtime
.store_config_bundle(default_embedded_config_bundle(&profile)) .store_config_bundle(bundle)
{ .map_err(|err| err.to_string())
}) {
Ok(availability) => availability.reference, Ok(availability) => availability.reference,
Err(error) => { Err(error) => {
diagnostics.push(embedded_runtime_diagnostic(&error)); diagnostics.push(diagnostic(
"embedded_profile_source_archive_invalid",
DiagnosticSeverity::Error,
error,
));
return WorkerSpawnResult { return WorkerSpawnResult {
state: WorkerOperationState::Rejected, state: WorkerOperationState::Rejected,
worker: None, worker: None,
@ -2018,7 +2025,18 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
.profile .profile
.clone() .clone()
.unwrap_or_else(|| embedded_profile_selector(&request.intent)); .unwrap_or_else(|| embedded_profile_selector(&request.intent));
let sync = self.sync_config_bundle(default_embedded_config_bundle(&profile)); let sync = match default_embedded_config_bundle(&profile) {
Ok(bundle) => self.sync_config_bundle(bundle),
Err(error) => ConfigBundleSyncResult {
state: WorkerOperationState::Rejected,
availability: None,
diagnostics: vec![diagnostic(
"remote_profile_source_archive_invalid",
DiagnosticSeverity::Error,
error,
)],
},
};
let Some(config_bundle) = sync.availability.map(|availability| availability.reference) let Some(config_bundle) = sync.availability.map(|availability| availability.reference)
else { else {
return WorkerSpawnResult { return WorkerSpawnResult {
@ -2347,14 +2365,14 @@ fn embedded_worker_execution_status_label(
} }
} }
fn default_embedded_config_bundle(profile: &ProfileSelector) -> ConfigBundle { fn default_embedded_config_bundle(profile: &ProfileSelector) -> Result<ConfigBundle, String> {
let id = format!( let id = format!(
"workspace-runtime-{}", "workspace-runtime-{}",
embedded_profile_label(profile) embedded_profile_label(profile)
.unwrap_or_else(|| "default".to_string()) .unwrap_or_else(|| "default".to_string())
.replace([':', '/', ' '], "-") .replace([':', '/', ' '], "-")
); );
ConfigBundle { Ok(ConfigBundle {
metadata: ConfigBundleMetadata { metadata: ConfigBundleMetadata {
id, id,
digest: String::new(), digest: String::new(),
@ -2371,8 +2389,76 @@ fn default_embedded_config_bundle(profile: &ProfileSelector) -> ConfigBundle {
label: embedded_profile_label(profile), label: embedded_profile_label(profile),
}], }],
declarations: Vec::new(), declarations: Vec::new(),
profile_source_archive: Some(default_profile_source_archive(profile)?),
}
.with_computed_digest())
}
fn default_profile_source_archive(
profile: &ProfileSelector,
) -> Result<ProfileSourceArchive, String> {
let selected = embedded_profile_label(profile).unwrap_or_else(|| "default".to_string());
let selected_path = embedded_profile_path(profile)?;
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, selected_path);
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,
})
.map_err(|err| err.to_string())
}
fn embedded_profile_path(profile: &ProfileSelector) -> Result<String, String> {
match profile {
ProfileSelector::RuntimeDefault => Ok("profiles/default.dcdl".to_string()),
ProfileSelector::Builtin(name) => match name.strip_prefix("builtin:").unwrap_or(name) {
"default" => Ok("profiles/default.dcdl".to_string()),
"companion" => Ok("profiles/companion.dcdl".to_string()),
"intake" => Ok("profiles/intake.dcdl".to_string()),
"orchestrator" => Ok("profiles/orchestrator.dcdl".to_string()),
"coder" => Ok("profiles/coder.dcdl".to_string()),
"reviewer" => Ok("profiles/reviewer.dcdl".to_string()),
other => Err(format!("unknown builtin profile selector: builtin:{other}")),
},
ProfileSelector::Named(name) => Err(format!("unknown named profile selector: {name}")),
} }
.with_computed_digest()
} }
fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector { fn embedded_profile_selector(intent: &WorkerSpawnIntent) -> ProfileSelector {
@ -2878,6 +2964,49 @@ mod tests {
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use std::thread; use std::thread;
#[test]
fn embedded_builtin_decodal_profiles_resolve_through_archive() {
let root = tempfile::tempdir().unwrap();
for selector in [
ProfileSelector::RuntimeDefault,
ProfileSelector::Builtin("builtin:companion".to_string()),
ProfileSelector::Builtin("builtin:intake".to_string()),
ProfileSelector::Builtin("builtin:orchestrator".to_string()),
ProfileSelector::Builtin("builtin:coder".to_string()),
ProfileSelector::Builtin("builtin:reviewer".to_string()),
] {
let bundle = default_embedded_config_bundle(&selector).unwrap();
let archive = bundle
.profile_source_archive
.as_ref()
.unwrap()
.verify()
.unwrap();
let selector_key = match &selector {
ProfileSelector::RuntimeDefault => "default".to_string(),
ProfileSelector::Builtin(name) => name.clone(),
ProfileSelector::Named(name) => name.clone(),
};
let manifest = archive
.resolve_profile(&selector_key, root.path(), "embedded-test-worker")
.unwrap();
assert_eq!(manifest.worker.name, "embedded-test-worker");
}
}
#[test]
fn embedded_archive_rejects_unknown_selectors() {
assert!(
default_embedded_config_bundle(&ProfileSelector::Builtin(
"builtin:missing".to_string()
))
.is_err()
);
assert!(
default_embedded_config_bundle(&ProfileSelector::Named("custom".to_string())).is_err()
);
}
fn test_config_bundle() -> ConfigBundle { fn test_config_bundle() -> ConfigBundle {
ConfigBundle { ConfigBundle {
metadata: worker_runtime::config_bundle::ConfigBundleMetadata { metadata: worker_runtime::config_bundle::ConfigBundleMetadata {
@ -2900,6 +3029,7 @@ mod tests {
name: "read".to_string(), name: "read".to_string(),
reference: "capability:read".to_string(), reference: "capability:read".to_string(),
}], }],
profile_source_archive: None,
} }
.with_computed_digest() .with_computed_digest()
} }

View File

@ -3405,6 +3405,7 @@ mod tests {
label: Some("server-test".to_string()), label: Some("server-test".to_string()),
}], }],
declarations: Vec::new(), declarations: Vec::new(),
profile_source_archive: None,
} }
.with_computed_digest() .with_computed_digest()
} }

View File

@ -43,7 +43,7 @@ rustPlatform.buildRustPackage rec {
filter = sourceFilter; filter = sourceFilter;
}; };
cargoHash = "sha256-ICEIo7wR65s6CQCzCVa1KaEou7fpnaUk64zwokb92Fc="; cargoHash = "sha256-g9Bh9qQ1fGZtzsjO2/vc4jZdrH5YoCR0rOgs//xUDrU=";
depsExtraArgs = { depsExtraArgs = {
# Older fetchCargoVendor utilities used crates.io's API download endpoint, # Older fetchCargoVendor utilities used crates.io's API download endpoint,

View File

@ -0,0 +1,12 @@
slug = "coder";
description = "Ticket implementation coder profile.";
scope = "workspace_write";
feature = {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = false; };
ticket = { enabled = false; access = "lifecycle"; };
ticket_orchestration = { enabled = false; };
};

View File

@ -0,0 +1,12 @@
slug = "companion";
description = "Workspace companion profile.";
scope = "workspace_write";
feature = {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = true; };
ticket = { enabled = false; access = "lifecycle"; };
ticket_orchestration = { enabled = false; };
};

View File

@ -0,0 +1,38 @@
slug = "default";
description = "Default Yoi coding profile.";
scope = "workspace_write";
model = {
ref = "codex-oauth/gpt-5.5";
};
session = {
record_event_trace = true;
};
engine = {
reasoning = "high";
};
feature = {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = true; };
ticket = { enabled = false; access = "lifecycle"; };
ticket_orchestration = { enabled = false; };
};
memory = {
extract_threshold = 50000;
consolidation_threshold_files = 5;
consolidation_threshold_bytes = 50000;
};
web = {
enabled = true;
search = {
provider = "brave";
api_key_secret = "web/brave/default";
};
};

View File

@ -0,0 +1,12 @@
slug = "intake";
description = "Ticket intake profile.";
scope = "workspace_write";
feature = {
task = { enabled = false; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = false; };
ticket = { enabled = true; access = "lifecycle"; };
ticket_orchestration = { enabled = false; };
};

View File

@ -0,0 +1,12 @@
slug = "orchestrator";
description = "Ticket orchestrator profile.";
scope = "workspace_write";
feature = {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = true; };
ticket = { enabled = true; access = "lifecycle"; };
ticket_orchestration = { enabled = true; };
};

View File

@ -0,0 +1,12 @@
slug = "reviewer";
description = "Ticket review profile.";
scope = "workspace_read";
feature = {
task = { enabled = true; };
memory = { enabled = true; };
web = { enabled = true; };
workers = { enabled = false; };
ticket = { enabled = false; access = "lifecycle"; };
ticket_orchestration = { enabled = false; };
};