feat: add decodal profile archives

This commit is contained in:
2026-07-08 18:57:54 +09:00
parent 56cac3c74a
commit a823d414d0
25 changed files with 930 additions and 21 deletions
+1 -1
View File
@@ -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};
+18 -1
View File
@@ -189,6 +189,10 @@ pub enum ProfileSource {
#[serde(default, skip_serializing_if = "Option::is_none")]
provenance: Option<String>,
},
Archive {
archive_id: String,
source: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -1405,6 +1409,10 @@ fn source_name(source: &ProfileSource) -> Option<String> {
.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<PathBuf, ProfileError> {
@@ -1438,12 +1446,21 @@ pub fn resolve_profile_artifact(
source: ProfileSource,
base_dir: &Path,
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> {
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,
+5 -3
View File
@@ -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 }
@@ -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<ConfigProfileDescriptor>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub declarations: Vec<ConfigDeclaration>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile_source_archive: Option<ProfileSourceArchive>,
}
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<crate::profile_archive::ProfileSourceGraphSummary>,
}
#[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<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(
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()
}
+2
View File
@@ -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<WorkingDirectoryBinding>,
pub config_bundle: Option<ConfigBundle>,
}
/// Result of backend Worker spawn/initialization.
+2
View File
@@ -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()
}
+1
View File
@@ -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;
@@ -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<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)
.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<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 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());
}
}
+9
View File
@@ -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()
}
+30 -8
View File
@@ -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()
}
+13
View File
@@ -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>,
+24
View File
@@ -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;
+68
View File
@@ -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()
}
+1
View File
@@ -3405,6 +3405,7 @@ mod tests {
label: Some("server-test".to_string()),
}],
declarations: Vec::new(),
profile_source_archive: None,
}
.with_computed_digest()
}