feat(profiles): seal builtin import provenance and expose read-only editor imports

Retain the evaluated JSON launch entrypoint, seal observed builtin source bytes and digests as verified provenance, and fail on missing/corrupt dependencies without Runtime builtin reevaluation. Cover real HTTP commits and launch resolution, including worker mode and Workspace Config feature composition.

Expose read-only builtin import completions/diagnostics in the editor and rebuild wasm32 release assets with wasm-bindgen 0.2.117. Document public paths, ordinary composition, limits, resave semantics, and the lazy-cycle incident.

Validation: worker-runtime 344 library + 34 integration tests; workspace-server 609 library + 11 binary tests plus final builtin HTTP/launch tests (2); archive tests (16); native WASM tests (2); real-WASM/web tests (37), editor component tests (10); root cargo check, cargo fmt --all -- --check and git diff --check HEAD pass.
This commit is contained in:
2026-10-07 03:38:49 +09:00
parent 4343be86c1
commit f086cf3375
12 changed files with 986 additions and 29 deletions
+205 -3
View File
@@ -134,15 +134,31 @@ impl ProfileSourceArchive {
selector: String,
value: serde_json::Value,
) -> Result<Self, ProfileArchiveError> {
Self::build_evaluated_profile_with_builtin_sources(id, selector, value, BTreeMap::new())
}
/// Seal the observed builtin import closure as provenance alongside the evaluated JSON.
/// Builtin sources are verified, but never evaluated to resolve this entrypoint.
pub fn build_evaluated_profile_with_builtin_sources(
id: String,
selector: String,
value: serde_json::Value,
sources: BTreeMap<String, String>,
) -> Result<Self, ProfileArchiveError> {
for key in sources.keys() {
validate_builtin_source_key(key)?;
}
let path = "profiles/evaluated.json".to_string();
let source = serde_json::to_string(&value)
.map_err(|err| ProfileArchiveError::Json(err.to_string()))?;
let mut sources = sources;
sources.insert(path.clone(), source);
Self::build_with_source_format(
ProfileSourceArchiveInput {
id,
entrypoints: BTreeMap::from([(selector, path.clone())]),
entrypoints: BTreeMap::from([(selector, path)]),
imports: BTreeMap::new(),
sources: BTreeMap::from([(path, source)]),
sources,
},
"json",
"application/json",
@@ -171,6 +187,12 @@ impl ProfileSourceArchive {
if total_source_bytes > MAX_TOTAL_BYTES {
return Err(ProfileArchiveError::LimitExceeded("total source bytes"));
}
let (kind, content_type) = if path.starts_with("$builtin/") {
validate_builtin_source_key(path)?;
("decodal", "text/x-decodal")
} else {
(kind, content_type)
};
source_meta.push(ProfileSourceArchiveSource {
path: path.clone(),
source_key: path.clone(),
@@ -310,7 +332,18 @@ impl VerifiedProfileSourceArchive {
let mut manifest_paths = BTreeSet::new();
for source in &manifest.sources {
validate_archive_path(&source.path)?;
if !source.source_key.is_empty() && source.source_key != source.path {
if source.path.starts_with("$builtin/") {
validate_builtin_source_key(&source.path)?;
if source.kind != "decodal" {
return Err(ProfileArchiveError::UnsupportedSource {
path: source.path.clone(),
kind: source.kind.clone(),
});
}
}
if (source.path.starts_with("$builtin/") || !source.source_key.is_empty())
&& source.source_key != source.path
{
return Err(ProfileArchiveError::ImportMapMismatch {
specifier: source.path.clone(),
expected: source.path.clone(),
@@ -662,6 +695,26 @@ fn append_bytes<W: Write>(
.map_err(|err| ProfileArchiveError::Io(err.to_string()))
}
fn validate_builtin_source_key(key: &str) -> Result<(), ProfileArchiveError> {
validate_archive_path(key)?;
let Some(relative) = key.strip_prefix("$builtin/profiles/") else {
return Err(ProfileArchiveError::UnsafePath(key.to_string()));
};
if !relative.ends_with(".dcdl")
|| relative.split('/').any(|part| {
part.is_empty()
|| part == "."
|| part == ".."
|| !part
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || b"-_.".contains(&byte))
})
{
return Err(ProfileArchiveError::UnsafePath(key.to_string()));
}
Ok(())
}
fn validate_archive_path(path: &str) -> Result<(), ProfileArchiveError> {
if path.is_empty() || path == MANIFEST_PATH {
return if path == MANIFEST_PATH {
@@ -799,6 +852,155 @@ mod tests {
archive.content = content;
}
fn builtin_provenance_archive() -> ProfileSourceArchive {
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
"builtin-provenance".into(),
"project:evaluated".into(),
evaluated_profile(),
BTreeMap::from([
(
"$builtin/profiles/companion.dcdl".into(),
"import \"./base.dcdl\"".into(),
),
// Intentionally invalid Decodal: provenance must not be reevaluated.
(
"$builtin/profiles/base.dcdl".into(),
"not valid Decodal !".into(),
),
]),
)
.unwrap()
}
#[test]
fn evaluated_json_seals_builtin_closure_without_runtime_evaluation() {
let archive = builtin_provenance_archive();
let verified = archive.verify().unwrap();
assert_eq!(
archive,
builtin_provenance_archive(),
"archive must be deterministic"
);
assert_eq!(archive.reference.source_graph.source_count, 3);
assert!(verified.manifest.imports.is_empty());
assert_eq!(
verified.manifest.entrypoints["project:evaluated"],
"profiles/evaluated.json"
);
for source in &verified.manifest.sources {
assert_eq!(source.source_key, source.path);
assert_eq!(
source.digest,
sha256_hex(verified.sources[&source.path].as_bytes())
);
if source.path.starts_with("$builtin/") {
assert_eq!(source.kind, "decodal");
assert_eq!(source.content_type, "text/x-decodal");
} else {
assert_eq!(source.kind, "json");
}
}
let root = tempfile::tempdir().unwrap();
let resolved = verified
.resolve_profile("project:evaluated", root.path(), "sealed-worker")
.unwrap();
assert_eq!(resolved.worker.mode, manifest::WorkerMode::Wip);
assert!(resolved.feature.task.enabled);
assert!(matches!(
verified.resolve_profile("builtin:companion", root.path(), "sealed-worker"),
Err(ProfileArchiveError::MissingEntrypoint(_))
));
}
#[test]
fn evaluated_json_rejects_missing_or_corrupt_builtin_dependency() {
for missing in [true, false] {
let mut archive = builtin_provenance_archive();
let mut verified = archive.verify().unwrap();
let path = "$builtin/profiles/base.dcdl";
if missing {
verified.sources.remove(path);
} else {
verified
.sources
.insert(path.into(), "corrupt dependency".into());
}
repack_archive(&mut archive, &verified.manifest, &verified.sources);
match archive.verify().unwrap_err() {
ProfileArchiveError::MissingSource(actual) if missing => assert_eq!(actual, path),
ProfileArchiveError::SourceDigestMismatch { path: actual, .. } if !missing => {
assert_eq!(actual, path)
}
other => panic!("unexpected error: {other}"),
}
}
}
#[test]
fn evaluated_json_builtin_provenance_rejects_unsafe_keys_and_reuses_limits() {
for key in [
"profiles/companion.dcdl",
"builtin:profiles/companion.dcdl",
"$builtin/prompts/companion.dcdl",
"$builtin/profiles/../escape.dcdl",
"$builtin/profiles/./base.dcdl",
"$builtin/profiles//base.dcdl",
"$builtin/profiles/base\\other.dcdl",
"$builtin/profiles/base\0.dcdl",
"/$builtin/profiles/base.dcdl",
"$builtin/profiles/base.json",
"profiles/evaluated.json",
] {
assert!(
matches!(
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
"unsafe".into(),
"project:evaluated".into(),
evaluated_profile(),
BTreeMap::from([(key.into(), "{}".into())]),
),
Err(ProfileArchiveError::UnsafePath(_))
),
"{key:?}"
);
}
assert!(matches!(
ProfileSourceArchive::build_evaluated_profile_with_builtin_sources(
"oversized".into(),
"project:evaluated".into(),
evaluated_profile(),
BTreeMap::from([(
"$builtin/profiles/base.dcdl".into(),
"x".repeat(MAX_SOURCE_BYTES as usize + 1)
)]),
),
Err(ProfileArchiveError::LimitExceeded("source bytes"))
));
}
#[test]
fn evaluated_json_builtin_provenance_requires_canonical_key_and_source_format() {
for key in ["", "profiles/base.dcdl", "$builtin/profiles/other.dcdl"] {
let mut archive = builtin_provenance_archive();
let mut verified = archive.verify().unwrap();
verified.manifest.sources[0].source_key = key.into();
repack_archive(&mut archive, &verified.manifest, &verified.sources);
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::ImportMapMismatch { .. })
));
}
let mut archive = builtin_provenance_archive();
let mut verified = archive.verify().unwrap();
verified.manifest.sources[0].kind = "json".into();
verified.manifest.sources[0].content_type = "application/json".into();
repack_archive(&mut archive, &verified.manifest, &verified.sources);
assert!(matches!(
archive.verify(),
Err(ProfileArchiveError::UnsupportedSource { .. })
));
}
#[test]
fn evaluated_json_reuses_source_size_limit() {
assert!(matches!(