fix: enforce workspace memory settings contract
This commit is contained in:
@@ -449,6 +449,17 @@ pub struct WebFetchConfig {
|
||||
pub allow_private_addresses: Option<bool>,
|
||||
}
|
||||
|
||||
/// Maximum Unicode scalar values accepted in a normalized Workspace Memory language name.
|
||||
pub const MAX_WORKSPACE_MEMORY_LANGUAGE_CHARS: usize = 64;
|
||||
|
||||
/// Return whether a Workspace Memory language is already normalized and safe to persist.
|
||||
pub fn is_normalized_workspace_memory_language(language: &str) -> bool {
|
||||
!language.is_empty()
|
||||
&& language == language.trim()
|
||||
&& language.chars().count() <= MAX_WORKSPACE_MEMORY_LANGUAGE_CHARS
|
||||
&& !language.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
/// Immutable Workspace Memory settings bound into a Worker launch snapshot.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -536,7 +547,7 @@ pub struct MemoryConfig {
|
||||
}
|
||||
|
||||
impl MemoryConfig {
|
||||
/// Replace any profile-authored language fields with a trusted Workspace snapshot.
|
||||
/// Replace any untrusted manifest values with a trusted Workspace snapshot.
|
||||
pub fn bind_workspace_settings(&mut self, snapshot: &WorkspaceMemorySettingsSnapshot) {
|
||||
self.workspace_id = Some(snapshot.workspace_id.clone());
|
||||
self.settings_revision = Some(snapshot.settings_revision);
|
||||
@@ -1256,6 +1267,18 @@ model_id = "claude-sonnet-4-20250514"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_memory_language_validation_is_bounded_free_form_utf8() {
|
||||
assert!(is_normalized_workspace_memory_language("Français"));
|
||||
assert!(is_normalized_workspace_memory_language("日本語"));
|
||||
assert!(!is_normalized_workspace_memory_language(""));
|
||||
assert!(!is_normalized_workspace_memory_language(" English "));
|
||||
assert!(!is_normalized_workspace_memory_language("English\n"));
|
||||
assert!(!is_normalized_workspace_memory_language(
|
||||
&"x".repeat(MAX_WORKSPACE_MEMORY_LANGUAGE_CHARS + 1)
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_section_with_language() {
|
||||
let toml = format!("{MINIMAL_REQUIRED}\n[memory]\nlanguage = \"Japanese\"\n");
|
||||
|
||||
@@ -562,7 +562,7 @@ fn resolve_profile_value(
|
||||
mcp: profile.mcp,
|
||||
compaction,
|
||||
web: profile.web,
|
||||
memory: profile.memory,
|
||||
memory: profile.memory.map(Into::into),
|
||||
skills: profile.skills,
|
||||
};
|
||||
let config = WorkerManifestConfig::builtin_defaults().merge(config.resolve_paths(profile_dir));
|
||||
@@ -582,6 +582,51 @@ fn resolve_profile_value(
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProfileMemoryConfig {
|
||||
#[serde(default)]
|
||||
workspace_root: Option<PathBuf>,
|
||||
#[serde(default)]
|
||||
query_result_limit: Option<usize>,
|
||||
#[serde(default)]
|
||||
query_excerpt_lines: Option<usize>,
|
||||
#[serde(default)]
|
||||
inject_summary: Option<bool>,
|
||||
#[serde(default)]
|
||||
extract_model: Option<ModelManifest>,
|
||||
#[serde(default)]
|
||||
extract_threshold: Option<u64>,
|
||||
#[serde(default)]
|
||||
extract_worker_max_turns: Option<u32>,
|
||||
#[serde(default)]
|
||||
consolidation_model: Option<ModelManifest>,
|
||||
#[serde(default)]
|
||||
consolidation_threshold_files: Option<usize>,
|
||||
#[serde(default)]
|
||||
consolidation_threshold_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
impl From<ProfileMemoryConfig> for MemoryConfig {
|
||||
fn from(profile: ProfileMemoryConfig) -> Self {
|
||||
Self {
|
||||
workspace_root: profile.workspace_root,
|
||||
query_result_limit: profile.query_result_limit,
|
||||
query_excerpt_lines: profile.query_excerpt_lines,
|
||||
inject_summary: profile.inject_summary,
|
||||
workspace_id: None,
|
||||
settings_revision: None,
|
||||
language: None,
|
||||
extract_model: profile.extract_model,
|
||||
extract_threshold: profile.extract_threshold,
|
||||
extract_worker_max_turns: profile.extract_worker_max_turns,
|
||||
consolidation_model: profile.consolidation_model,
|
||||
consolidation_threshold_files: profile.consolidation_threshold_files,
|
||||
consolidation_threshold_bytes: profile.consolidation_threshold_bytes,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ProfileConfig {
|
||||
@@ -612,7 +657,7 @@ struct ProfileConfig {
|
||||
#[serde(default)]
|
||||
web: Option<WebConfig>,
|
||||
#[serde(default)]
|
||||
memory: Option<MemoryConfig>,
|
||||
memory: Option<ProfileMemoryConfig>,
|
||||
#[serde(default)]
|
||||
skills: Option<SkillsConfig>,
|
||||
}
|
||||
@@ -1334,6 +1379,34 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_rejects_workspace_memory_snapshot_authority_fields() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
for (field, value) in [
|
||||
("workspace_id", serde_json::json!("workspace-a")),
|
||||
("settings_revision", serde_json::json!(2)),
|
||||
("language", serde_json::json!("Japanese")),
|
||||
] {
|
||||
let artifact = serde_json::json!({ "memory": { (field): value } });
|
||||
let error = resolve_profile_artifact_value(
|
||||
artifact,
|
||||
ProfileSource::Registry {
|
||||
source: ProfileRegistrySource::Builtin,
|
||||
name: "test".to_string(),
|
||||
path: None,
|
||||
provenance: None,
|
||||
},
|
||||
tmp.path(),
|
||||
"test-worker",
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
error.to_string().contains("unknown field"),
|
||||
"unexpected error for {field}: {error}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builtin_companion_can_manage_workdirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user