diff --git a/Cargo.lock b/Cargo.lock index 44c195a8ac..54b014e354 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6150,6 +6150,7 @@ dependencies = [ "openbitfun-product-domains", "openbitfun-relay-service", "openbitfun-runtime-ports", + "openbitfun-services-core", "openbitfun-services-integrations", "openbitfun-transport", "openbitfun-webdriver", @@ -6594,6 +6595,7 @@ dependencies = [ "git2", "globset", "ignore", + "image 0.25.10", "libc", "log", "openbitfun-core-types", @@ -6612,9 +6614,11 @@ dependencies = [ "tempfile", "thiserror 2.0.19", "tokio", + "uuid", "which 8.0.5", "win32job", "windows 0.61.3", + "zip 4.6.1", ] [[package]] diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 093c94f7f1..b680bee0e3 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -4818,7 +4818,7 @@ "visibility": "documented", "rustPath": "list_agent_companion_pets", "sourceFile": "src/apps/desktop/src/api/commands.rs", - "signature": "fn list_agent_companion_pets( state: State<'_, AppState>, ) -> Result", + "signature": "fn list_agent_companion_pets( state: State<'_, AppState>, request: Option, ) -> Result", "remoteWorkspacePolicy": "LegacyUnaudited" }, { diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index ea46ec10c9..ecc60a51d3 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -539,6 +539,13 @@ const THIRD_PARTY_CAPABILITY_PROFILES = new Map([ ['image', { label: 'Image', packages: new Map([ + ['openbitfun-services-core', dependencyProfile([], { + optional: true, + useDefaultFeatures: false, + ownerFeatureCapabilities: new Map([ + ['pet-packages', ['gif', 'jpeg', 'png', 'webp']], + ]), + })], ['openbitfun-cli', dependencyProfile(['gif', 'jpeg', 'png', 'webp'], { useDefaultFeatures: false, })], diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 926c055ba0..06104dff3a 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -43,10 +43,13 @@ export const optionalDependencyFeatureOwnerRules = [ reason: 'services-core optional implementation dependencies must stay behind their exact owner capability', dependencies: [ + { depName: 'image', ownerFeatures: ['pet-packages'] }, + { depName: 'zip', ownerFeatures: ['pet-packages'] }, + { depName: 'uuid', ownerFeatures: ['pet-packages'] }, { depName: 'aes-gcm', ownerFeatures: ['credential-vault'] }, { depName: 'anyhow', ownerFeatures: ['credential-vault', 'dispatch-workspace', 'workspace-runtime', 'workspace-transfer'] }, { depName: 'async-trait', ownerFeatures: ['permission', 'workspace-runtime'] }, - { depName: 'base64', ownerFeatures: ['credential-vault', 'filesystem'] }, + { depName: 'base64', ownerFeatures: ['pet-packages', 'credential-vault', 'filesystem'] }, { depName: 'openbitfun-core-types', ownerFeatures: ['filesystem', 'local-storage', 'product-identity', 'workspace-persistence'], @@ -55,8 +58,8 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'openbitfun-runtime-ports', ownerFeatures: ['permission', 'workspace-runtime', 'workspace-persistence', 'workspace-transfer'] }, { depName: 'chrono', ownerFeatures: ['filesystem', 'local-storage', 'workspace-persistence'] }, { depName: 'chrono-tz', ownerFeatures: ['token-usage-statistics'] }, - { depName: 'dunce', ownerFeatures: ['runtime-ownership', 'workspace-identity', 'workspace-runtime'] }, - { depName: 'fs2', ownerFeatures: ['credential-vault', 'json-io', 'local-storage', 'runtime-ownership'] }, + { depName: 'dunce', ownerFeatures: ['pet-packages', 'runtime-ownership', 'workspace-identity', 'workspace-runtime'] }, + { depName: 'fs2', ownerFeatures: ['pet-packages', 'credential-vault', 'json-io', 'local-storage', 'runtime-ownership'] }, { depName: 'git2', ownerFeatures: ['session-git'] }, { depName: 'globset', ownerFeatures: ['workspace-instructions'] }, { depName: 'ignore', ownerFeatures: ['filesystem'] }, @@ -79,6 +82,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'sha2', ownerFeatures: [ + 'pet-packages', 'dispatch-workspace', 'filesystem', 'local-storage', @@ -89,7 +93,7 @@ export const optionalDependencyFeatureOwnerRules = [ }, { depName: 'which', ownerFeatures: ['process-runtime'] }, { depName: 'win32job', ownerFeatures: ['process-runtime'] }, - { depName: 'windows', ownerFeatures: ['json-io', 'local-storage', 'process-runtime'] }, + { depName: 'windows', ownerFeatures: ['installed-apps', 'json-io', 'local-storage', 'process-runtime'] }, { depName: 'tokio', ownerFeatures: [ diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 5e49106363..e4adf6ee91 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -10,6 +10,7 @@ export const publicApiContractSlices = [ 'external-source-tool-contract', 'external-source-subagent-contract', 'external-source-mcp-contract', + 'external-source-pet-contract', 'external-source-hook-contract', 'external-source-reference-contract', 'user-instruction-source-boundary', @@ -67,6 +68,7 @@ const contractSlices = { externalSourceToolContract: 'external-source-tool-contract', externalSourceSubagentContract: 'external-source-subagent-contract', externalSourceMcpContract: 'external-source-mcp-contract', + externalSourcePetContract: 'external-source-pet-contract', externalSourceHookContract: 'external-source-hook-contract', externalSourceReferenceContract: 'external-source-reference-contract', userInstructionSourceBoundary: 'user-instruction-source-boundary', @@ -539,7 +541,17 @@ export const codexAdapterPublicApiEntries = [ ))).concat([ 'load_codex_user_instructions', 'CodexInstructionSourceOptions', -].map((symbol) => userInstructionSourceAdapterEntry(symbol, 'Codex'))); +].map((symbol) => userInstructionSourceAdapterEntry(symbol, 'Codex'))).concat(['pet_source_root', 'builtin_pet_sources', 'BuiltinPetCatalog', 'BuiltinPetSource'].map((symbol) => ({ + symbol, + owner: 'Codex adapter static pet source owner', + consumer: 'openbitfun-core external source composition facade', + verification: 'Codex pet source fixtures, installed bundle smoke test, and services-core pet package tests', + p0: 'local custom and bundled pet source discovery without execution or installation', + contractSlice: contractSlices.externalSourcePetContract, + wireImpact: false, + rationale: 'the adapter translates Codex package metadata; portable services own bounded resource IO and native copying', + exit: 'remove only when an equivalent typed pet source provider owns this root', +}))); export const staticHookSupportPublicApiEntries = [ // Shared by DSH/PI only for validated, redacted source/event construction. @@ -1211,6 +1223,9 @@ export const externalSourceCorePublicApiEntries = [ 'NativePromptCommandConflictSnapshot', 'native_prompt_command_conflicts', 'instruction_source_catalog', + 'external_pet_source_root', + 'external_builtin_pet_sources', + 'ExternalBuiltinPetCatalog', 'InstructionSourceCatalog', 'set_native_prompt_command_conflict_choice', 'external_source_conflict_choices', diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 70270a6007..9bd5fae4ae 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -18,6 +18,7 @@ tauri-build = { workspace = true } serde_json = { workspace = true } [dependencies] +openbitfun-services-core = { path = "../../crates/services/services-core", features = ["pet-packages"] } # Internal crates openbitfun-core = { path = "../../crates/assembly/core", features = ["product-full"] } openbitfun-relay-service = { path = "../../crates/services/relay-service" } diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index fc6cedd922..7c22a7f17b 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -480,6 +480,10 @@ pub struct ReadFileContentRequest { #[serde(rename_all = "camelCase")] pub struct ImportAgentCompanionPetPackageRequest { pub path: String, + #[serde(default)] + pub expected_fingerprint: Option, + #[serde(default)] + pub builtin_id: Option, } #[derive(Debug, Deserialize)] @@ -488,23 +492,25 @@ pub struct DeleteAgentCompanionPetPackageRequest { pub package_path: String, } -#[derive(Debug, Serialize)] +pub use openbitfun_services_core::pet_packages::PetPackage as AgentCompanionPetPackageDto; + +#[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct AgentCompanionPetPackageDto { - pub id: String, - pub display_name: String, - pub description: Option, - pub source: String, - pub package_path: String, - pub spritesheet_path: String, - pub spritesheet_mime_type: String, - pub sprite_version_number: u32, +pub struct ListAgentCompanionPetsRequest { + #[serde(default)] + pub include_external: bool, + #[serde(default)] + pub builtin_import_version: Option, } #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct ListAgentCompanionPetsResponse { pub pets: Vec, + pub import_operations_version: u32, + pub builtin_import_version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub external: Option, } #[derive(Debug, Deserialize)] @@ -2457,12 +2463,6 @@ pub async fn read_file_content( .await } -struct PetPackageSource { - pet_json: Vec, - spritesheet_name: PathBuf, - spritesheet: Vec, -} - fn sanitize_pet_id(id: &str) -> String { let sanitized: String = id .chars() @@ -2525,6 +2525,62 @@ fn pet_sprite_version(manifest: &serde_json::Value) -> Result { mod pet_package_tests { use super::*; + #[test] + fn legacy_pet_requests_keep_optional_review_fields() { + let old: ImportAgentCompanionPetPackageRequest = + serde_json::from_value(serde_json::json!({"path": "legacy-pet"})).unwrap(); + assert!(old.expected_fingerprint.is_none()); + assert!(old.builtin_id.is_none()); + let request: ListAgentCompanionPetsRequest = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(!request.include_external); + assert!(request.builtin_import_version.is_none()); + } + + #[test] + fn bundled_pet_request_requires_review() { + let temp = tempfile::tempdir().unwrap(); + let request = + serde_json::from_value(serde_json::json!({"path":"app.asar", "builtinId":"codex"})) + .unwrap(); + assert!(import_pet_request(temp.path(), request) + .unwrap_err() + .contains("reviewed fingerprint")); + } + + #[test] + #[ignore = "Imports installed Codex pets into an isolated temporary directory"] + fn installed_builtin_pets_can_be_previewed_and_imported() { + let temp = tempfile::tempdir().unwrap(); + let sources = + openbitfun_core::external_sources::external_builtin_pet_sources("codex", None); + assert!(sources.diagnostics.is_empty(), "{:?}", sources.diagnostics); + assert!(!sources.pets.is_empty()); + for source in sources.pets { + let identity = format!("codex:builtin:{}", source.id); + let candidate = openbitfun_services_core::pet_packages::candidate_from_bytes( + temp.path(), + &identity, + &source.archive_path, + &source.manifest, + source.image, + ) + .unwrap(); + assert!(candidate + .preview_data_url + .starts_with("data:image/png;base64,")); + let request = serde_json::from_value(serde_json::json!({ + "path": source.archive_path, "builtinId":source.id, "expectedFingerprint":candidate.fingerprint, + })).unwrap(); + let imported = import_pet_request(temp.path(), request).unwrap(); + assert!(Path::new(&imported.spritesheet_path).is_file()); + assert_eq!( + imported.sprite_version_number, + candidate.pet.sprite_version_number + ); + println!("Validated bundled pet: {}", imported.display_name); + } + } #[test] fn pet_manifest_versions_are_validated_and_exported() { for version in [None, Some(1), Some(2)] { @@ -2560,71 +2616,6 @@ mod pet_package_tests { } } -fn load_pet_package_source(source_path: &Path) -> Result { - if source_path.is_dir() { - let pet_json_path = source_path.join("pet.json"); - let pet_json = - std::fs::read(&pet_json_path).map_err(|e| format!("Failed to read pet.json: {}", e))?; - let (_, spritesheet_name) = load_pet_manifest_from_bytes(&pet_json)?; - let spritesheet_path = source_path.join(&spritesheet_name); - let spritesheet = std::fs::read(&spritesheet_path) - .map_err(|e| format!("Failed to read spritesheet: {}", e))?; - return Ok(PetPackageSource { - pet_json, - spritesheet_name, - spritesheet, - }); - } - - let file = std::fs::File::open(source_path) - .map_err(|e| format!("Failed to open pet zip package: {}", e))?; - let mut archive = - zip::ZipArchive::new(file).map_err(|e| format!("Failed to read pet zip package: {}", e))?; - - let mut manifest_index = None; - for index in 0..archive.len() { - let entry = archive - .by_index(index) - .map_err(|e| format!("Failed to inspect pet zip package: {}", e))?; - if Path::new(entry.name()).file_name().and_then(|n| n.to_str()) == Some("pet.json") { - manifest_index = Some(index); - break; - } - } - let manifest_index = - manifest_index.ok_or_else(|| "Pet package must contain pet.json".to_string())?; - - let mut pet_json = Vec::new(); - let manifest_name = { - let mut manifest_file = archive - .by_index(manifest_index) - .map_err(|e| format!("Failed to open pet.json in zip package: {}", e))?; - std::io::copy(&mut manifest_file, &mut pet_json) - .map_err(|e| format!("Failed to read pet.json from zip package: {}", e))?; - PathBuf::from(manifest_file.name()) - }; - let (_, spritesheet_name) = load_pet_manifest_from_bytes(&pet_json)?; - let spritesheet_zip_path = manifest_name - .parent() - .unwrap_or_else(|| Path::new("")) - .join(&spritesheet_name) - .to_string_lossy() - .replace('\\', "/"); - - let mut spritesheet = Vec::new(); - let mut spritesheet_file = archive - .by_name(&spritesheet_zip_path) - .map_err(|e| format!("Failed to open spritesheet in zip package: {}", e))?; - std::io::copy(&mut spritesheet_file, &mut spritesheet) - .map_err(|e| format!("Failed to read spritesheet from zip package: {}", e))?; - - Ok(PetPackageSource { - pet_json, - spritesheet_name, - spritesheet, - }) -} - fn companion_user_packages_dir(state: &AppState) -> PathBuf { state .workspace_service @@ -2692,7 +2683,10 @@ fn scan_pet_package_dirs(root: &Path, source: &str) -> Vec Vec, + request: Option, ) -> Result { - list_agent_companion_pets_impl(&state).await + let include_builtins = request + .as_ref() + .is_some_and(|r| r.builtin_import_version == Some(1)); + let mut response = list_agent_companion_pets_impl(&state).await?; + if request.is_some_and(|r| r.include_external) { + let source_root = openbitfun_core::external_sources::external_pet_source_root("codex") + .ok_or("Pet source is unavailable")?; + let installed_root = companion_user_packages_dir(&state); + response.external = Some( + tokio::task::spawn_blocking(move || { + let mut catalog = + openbitfun_services_core::pet_packages::catalog(&source_root, &installed_root); + if !include_builtins { + return catalog; + } + let bundled = + openbitfun_core::external_sources::external_builtin_pet_sources("codex", None); + catalog.diagnostics.extend(bundled.diagnostics); + for source in bundled.pets { + let identity = format!("codex:builtin:{}", source.id); + match openbitfun_services_core::pet_packages::candidate_from_bytes( + &installed_root, + &identity, + &source.archive_path, + &source.manifest, + source.image, + ) { + Ok(mut candidate) => { + candidate.builtin_id = Some(source.id); + catalog.candidates.push(candidate); + } + Err(error) => catalog + .diagnostics + .push(format!("Codex built-in pet {}: {error}", source.id)), + } + } + catalog + }) + .await + .map_err(|e| e.to_string())?, + ); + } + Ok(response) } pub(crate) async fn list_agent_companion_pets_impl( state: &AppState, ) -> Result { let pets = scan_pet_package_dirs(&companion_user_packages_dir(&state), "user"); - Ok(ListAgentCompanionPetsResponse { pets }) + Ok(ListAgentCompanionPetsResponse { + pets, + import_operations_version: 1, + builtin_import_version: 1, + external: None, + }) } #[tauri::command] @@ -2727,85 +2769,56 @@ pub async fn import_agent_companion_pet_package( state: State<'_, AppState>, request: ImportAgentCompanionPetPackageRequest, ) -> Result { - import_agent_companion_pet_package_impl(&state, &request.path).await + let root = companion_user_packages_dir(&state); + tokio::task::spawn_blocking(move || import_pet_request(&root, request)) + .await + .map_err(|e| e.to_string())? } -pub(crate) async fn import_agent_companion_pet_package_impl( - state: &AppState, - source_path: &str, +fn import_pet_request( + root: &Path, + request: ImportAgentCompanionPetPackageRequest, ) -> Result { - let source_path = PathBuf::from(source_path); - let source = load_pet_package_source(&source_path)?; - let (pet_json, _) = load_pet_manifest_from_bytes(&source.pet_json)?; - let sprite_version_number = pet_sprite_version(&pet_json)?; - - let raw_id = pet_json - .get("id") - .and_then(|value| value.as_str()) - .unwrap_or("custom-pet"); - let id = sanitize_pet_id(raw_id); - let display_name = pet_json - .get("displayName") - .and_then(|value| value.as_str()) - .filter(|value| !value.trim().is_empty()) - .unwrap_or(raw_id) - .trim() - .to_string(); - let description = pet_json - .get("description") - .and_then(|value| value.as_str()) - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()); - - let package_dir = state - .workspace_service - .path_manager() - .user_data_dir() - .join("agent-companions") - .join(format!("{}-{}", id, uuid::Uuid::new_v4().simple())); - - std::fs::create_dir_all(&package_dir) - .map_err(|e| format!("Failed to create pet package directory: {}", e))?; - - let spritesheet_file_name = source - .spritesheet_name - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("spritesheet.webp") - .to_string(); - let spritesheet_path = package_dir.join(&spritesheet_file_name); - - let mut normalized_manifest = pet_json; - if let Some(obj) = normalized_manifest.as_object_mut() { - obj.insert( - "spritesheetPath".to_string(), - serde_json::Value::String(spritesheet_file_name.clone()), + if let Some(id) = request.builtin_id { + let expected = request + .expected_fingerprint + .as_deref() + .ok_or("Built-in pet import requires a reviewed fingerprint")?; + let mut catalog = + openbitfun_core::external_sources::external_builtin_pet_sources("codex", Some(&id)); + let source = catalog.pets.pop().ok_or_else(|| { + if catalog.diagnostics.is_empty() { + "Built-in pet is unavailable".to_string() + } else { + catalog.diagnostics.join("; ") + } + })?; + return openbitfun_services_core::pet_packages::import_bytes( + &root, + &format!("codex:builtin:{id}"), + &source.manifest, + source.image, + expected, ); } + openbitfun_services_core::pet_packages::import( + &root, + Path::new(&request.path), + request.expected_fingerprint.as_deref(), + ) +} - let manifest_bytes = serde_json::to_vec_pretty(&normalized_manifest) - .map_err(|e| format!("Failed to serialize pet.json: {}", e))?; - std::fs::write(package_dir.join("pet.json"), manifest_bytes) - .map_err(|e| format!("Failed to write pet.json: {}", e))?; - std::fs::write(&spritesheet_path, source.spritesheet) - .map_err(|e| format!("Failed to write spritesheet: {}", e))?; - - info!( - "Imported Agent companion pet package '{}' into {}", - id, - package_dir.display() - ); - - Ok(AgentCompanionPetPackageDto { - id, - display_name, - description, - source: "user".to_string(), - package_path: package_dir.to_string_lossy().to_string(), - spritesheet_path: spritesheet_path.to_string_lossy().to_string(), - spritesheet_mime_type: spritesheet_mime_type(&spritesheet_file_name).to_string(), - sprite_version_number, +pub(crate) async fn import_agent_companion_pet_package_impl( + state: &AppState, + source_path: &str, +) -> Result { + let root = companion_user_packages_dir(state); + let source_path = source_path.to_string(); + tokio::task::spawn_blocking(move || { + openbitfun_services_core::pet_packages::import(&root, Path::new(&source_path), None) }) + .await + .map_err(|e| e.to_string())? } #[tauri::command] @@ -2833,7 +2846,7 @@ pub(crate) async fn delete_agent_companion_pet_package_impl( .canonicalize() .map_err(|e| format!("Pet package path not found: {}", e))?; - if !resolved.starts_with(&root) { + if resolved.parent() != Some(root.as_path()) { return Err( "Refusing to delete path outside imported Agent companion packages".to_string(), ); diff --git a/src/crates/adapters/codex-adapter/AGENTS.md b/src/crates/adapters/codex-adapter/AGENTS.md index aae1bd4e61..24e2623c86 100644 --- a/src/crates/adapters/codex-adapter/AGENTS.md +++ b/src/crates/adapters/codex-adapter/AGENTS.md @@ -5,3 +5,11 @@ - Preserve native Codex event and handler names. Map only reviewed OpenBitFun Hook points; report every other event as native-only. - Preserve Codex configuration-layer and field-overlay semantics inside each typed provider. Unknown behavior fields must block explicitly; do not invent Codex Command or standalone Tool sources. - Do not depend on another ecosystem adapter. Shared product behavior belongs in contracts or assembly. + +## Pet source verification + +```bash +cargo test -p openbitfun-codex-adapter --lib pet_source +# Optional read-only verification on a host with the Codex desktop app installed: +cargo test -p openbitfun-codex-adapter --lib installed_builtin_pet_catalog -- --ignored --nocapture +``` diff --git a/src/crates/adapters/codex-adapter/Cargo.toml b/src/crates/adapters/codex-adapter/Cargo.toml index 276ebb9bfe..7db933a3fe 100644 --- a/src/crates/adapters/codex-adapter/Cargo.toml +++ b/src/crates/adapters/codex-adapter/Cargo.toml @@ -12,7 +12,7 @@ crate-type = ["rlib"] [dependencies] openbitfun-product-domains = { path = "../../contracts/product-domains", features = ["external-sources"] } -openbitfun-services-core = { path = "../../services/services-core" } +openbitfun-services-core = { path = "../../services/services-core", features = ["asar", "installed-apps"] } openbitfun-static-hook-support = { path = "../static-hook-support" } dirs = { workspace = true } dunce = { workspace = true } diff --git a/src/crates/adapters/codex-adapter/src/lib.rs b/src/crates/adapters/codex-adapter/src/lib.rs index 6c140ccc6f..3ee8153781 100644 --- a/src/crates/adapters/codex-adapter/src/lib.rs +++ b/src/crates/adapters/codex-adapter/src/lib.rs @@ -4,8 +4,35 @@ mod agent_source; mod hook_source; mod instruction_source; mod mcp_source; +mod pet_source; + +pub use pet_source::{builtin_pet_sources, BuiltinPetCatalog, BuiltinPetSource}; pub use agent_source::{CodexSubagentProvider, CodexSubagentProviderOptions}; pub use hook_source::{CodexHookProvider, CodexHookProviderOptions}; pub use instruction_source::{load_codex_user_instructions, CodexInstructionSourceOptions}; pub use mcp_source::{CodexMcpProvider, CodexMcpProviderOptions}; + +/// User-installed Petdex packages; shares the configurable Codex root. +pub fn pet_source_root() -> std::path::PathBuf { + CodexHookProviderOptions::from_environment() + .codex_home + .join("pets") +} + +#[cfg(test)] +mod tests { + #[test] + fn pet_root_uses_configured_codex_home() { + // This crate has no other environment-mutating unit tests. + let previous = std::env::var_os("CODEX_HOME"); + let expected = std::env::temp_dir().join("openbitfun-codex-pet-root"); + std::env::set_var("CODEX_HOME", &expected); + let actual = super::pet_source_root(); + match previous { + Some(value) => std::env::set_var("CODEX_HOME", value), + None => std::env::remove_var("CODEX_HOME"), + } + assert_eq!(actual, expected.join("pets")); + } +} diff --git a/src/crates/adapters/codex-adapter/src/pet_source.rs b/src/crates/adapters/codex-adapter/src/pet_source.rs new file mode 100644 index 0000000000..6bf83bf2db --- /dev/null +++ b/src/crates/adapters/codex-adapter/src/pet_source.rs @@ -0,0 +1,338 @@ +//! Static discovery of bundled pets. Bundle JavaScript is inspected as data, never evaluated. +use openbitfun_services_core::asar::AsarArchive; +use serde_json::{json, Value}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, +}; + +#[derive(Debug)] +pub struct BuiltinPetSource { + pub id: String, + pub archive_path: PathBuf, + pub resource_path: String, + pub manifest: Vec, + pub image: Vec, +} + +#[derive(Debug, Default)] +pub struct BuiltinPetCatalog { + pub pets: Vec, + pub diagnostics: Vec, +} + +/// Resolve the current installation each time, so an app update cannot pin stale resources. +pub fn builtin_pet_sources(only_id: Option<&str>) -> BuiltinPetCatalog { + let mut result = BuiltinPetCatalog::default(); + let paths = installation_archives(&mut result.diagnostics); + let Some(path) = paths.into_iter().find(|path| path.is_file()) else { + result + .diagnostics + .push("Codex built-in pets: no readable application bundle was found".into()); + return result; + }; + match read_bundle(&path, only_id) { + Ok(pets) => result.pets = pets, + Err(error) => result + .diagnostics + .push(format!("Codex built-in pets ({}): {error}", path.display())), + } + result +} + +fn installation_archives(diagnostics: &mut Vec) -> Vec { + let mut paths = Vec::new(); + #[cfg(target_os = "windows")] + { + match openbitfun_services_core::installed_apps::windows_package_roots("OpenAI.Codex") { + Ok(packages) => paths.extend(sort_windows_packages(packages)), + Err(error) => diagnostics.push(format!( + "Could not inspect Codex app registrations: {error}" + )), + } + if let Some(local) = std::env::var_os("LOCALAPPDATA") { + let local = PathBuf::from(local); + for suffix in [ + "Programs/Codex/resources/app.asar", + "Programs/ChatGPT/resources/app.asar", + "OpenAI/Codex/resources/app.asar", + "Codex/resources/app.asar", + ] { + paths.push(local.join(suffix)); + } + } + } + #[cfg(target_os = "macos")] + { + for root in [ + Some(PathBuf::from("/Applications")), + dirs::home_dir().map(|v| v.join("Applications")), + ] + .into_iter() + .flatten() + { + for app in ["Codex.app", "ChatGPT.app"] { + paths.push(root.join(app).join("Contents/Resources/app.asar")); + } + } + } + #[cfg(target_os = "linux")] + { + for root in ["/opt/Codex", "/opt/codex", "/opt/ChatGPT", "/usr/lib/codex"] { + paths.push(PathBuf::from(root).join("resources/app.asar")); + } + if let Some(home) = dirs::home_dir() { + paths.push(home.join(".local/share/codex/resources/app.asar")); + } + } + let _ = diagnostics; + paths +} + +#[cfg(any(target_os = "windows", test))] +fn sort_windows_packages(entries: Vec<(String, PathBuf)>) -> Vec { + let mut packages = Vec::new(); + for (name, root) in entries { + let Some(suffix) = name.strip_prefix("OpenAI.Codex_") else { + continue; + }; + let Some(version) = suffix.split('_').next().and_then(|v| { + v.split('.') + .map(str::parse::) + .collect::, _>>() + .ok() + }) else { + continue; + }; + if version.len() != 4 { + continue; + } + packages.push((version, root.join("app/resources/app.asar"))); + } + packages.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| a.1.cmp(&b.1))); + packages.into_iter().map(|(_, path)| path).collect() +} + +fn literal_field<'a>(object: &'a str, key: &str) -> Option<&'a str> { + let (_, rest) = object.split_once(&format!("{key}:"))?; + let rest = rest.trim_start(); + let quote = rest.chars().next()?; + if !['`', '\'', '"'].contains("e) { + return None; + } + let value = rest[1..].split(quote).next()?; + if value.contains('\\') || value.contains("${") { + return None; + } + Some(value) +} + +fn definitions(script: &str) -> Result, String> { + let mut pets = BTreeMap::new(); + for object in script.split('{').filter_map(|s| s.split('}').next()) { + if !object.contains("assetRef:") || !object.contains("spriteVersionNumber:") { + continue; + } + let (Some(asset), Some(id), Some(name)) = ( + literal_field(object, "assetRef"), + literal_field(object, "id"), + literal_field(object, "displayName"), + ) else { + continue; + }; + if id.is_empty() + || !id + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') + { + continue; + } + let version = object + .split_once("spriteVersionNumber:") + .and_then(|(_, s)| s.trim_start().split(|c: char| !c.is_ascii_digit()).next()) + .and_then(|s| s.parse::().ok()) + .ok_or("Unknown bundled pet sprite version")?; + if version != 1 && version != 2 { + return Err("Unsupported bundled pet sprite version".into()); + } + let manifest = json!({"id":id,"displayName":name,"description":literal_field(object,"description"),"spriteVersionNumber":version,"spritesheetPath":"spritesheet.webp"}); + if let Some(previous) = pets.insert(id.into(), (asset.into(), manifest.clone())) { + if previous != (asset.into(), manifest) { + return Err("Conflicting bundled pet definitions".into()); + } + } + } + Ok(pets) +} + +fn read_bundle(path: &Path, only_id: Option<&str>) -> Result, String> { + let mut archive = AsarArchive::open(path)?; + let names = archive.file_names("webview/assets")?; + let maps: Vec<_> = names + .iter() + .filter(|n| n.starts_with("codex-pet-assets-") && n.ends_with(".js")) + .collect(); + if maps.len() != 1 { + return Err("Unsupported built-in pet asset map".into()); + } + let map = String::from_utf8(archive.read(&format!("webview/assets/{}", maps[0]), 1024 * 1024)?) + .map_err(|e| e.to_string())?; + let scripts: Vec<_> = names + .iter() + .filter(|n| n.starts_with("app-initial-") && n.ends_with(".js")) + .collect(); + if scripts.len() != 1 { + return Err("Unsupported built-in pet metadata bundle".into()); + } + let script = String::from_utf8( + archive.read(&format!("webview/assets/{}", scripts[0]), 32 * 1024 * 1024)?, + ) + .map_err(|e| e.to_string())?; + let definitions = definitions(&script)?; + if definitions.is_empty() || definitions.len() > 128 { + return Err("Built-in pet definitions are missing or exceed the limit".into()); + } + let mut pets = Vec::new(); + let mut total_bytes = 0; + for (id, (asset, manifest)) in definitions { + if only_id.is_some_and(|requested| requested != id) { + continue; + } + let matches: Vec<_> = names + .iter() + .filter(|n| { + n.starts_with(&format!("{asset}-spritesheet-")) + && n.ends_with(".webp") + && ['`', '\'', '"'] + .iter() + .any(|q| map.contains(&format!("{q}{n}{q}"))) + }) + .collect(); + if matches.len() != 1 { + return Err(format!("Missing or ambiguous built-in pet image: {id}")); + } + let resource_path = format!("webview/assets/{}", matches[0]); + let image = archive.read(&resource_path, 32 * 1024 * 1024)?; + total_bytes += image.len(); + if total_bytes > 64 * 1024 * 1024 { + return Err("Bundled pet resource total exceeds its limit".into()); + } + pets.push(BuiltinPetSource { + id, + archive_path: path.into(), + image, + resource_path, + manifest: serde_json::to_vec(&manifest).map_err(|e| e.to_string())?, + }); + } + if only_id.is_some() && pets.is_empty() { + return Err("Requested built-in pet is no longer available".into()); + } + Ok(pets) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn static_definitions_preserve_names_and_versions_without_evaluation() { + let script = "[{assetRef:`owl`,description:`Calm owl`,displayName:`Owl`,id:`owl`,spriteVersionNumber:2},{assetRef:run(),displayName:`Dynamic`,id:`dynamic`,spriteVersionNumber:2}]"; + let pets = definitions(script).unwrap(); + assert_eq!(pets.len(), 1); + assert_eq!(pets["owl"].1["displayName"], "Owl"); + assert_eq!(pets["owl"].1["spriteVersionNumber"], 2); + assert!( + definitions(&script.replace("spriteVersionNumber:2", "spriteVersionNumber:3")).is_err() + ); + } + #[test] + fn windows_installations_sort_numeric_versions_and_ignore_other_apps() { + let entries = [ + "OpenAI.Codex_26.9.1.0_x64__publisher", + "OpenAI.Codex_26.10.1.0_x64__publisher", + "Other_99.0.0.0", + "OpenAI.Codex_invalid", + ] + .map(|name| (name.into(), PathBuf::from(name))) + .to_vec(); + let paths = sort_windows_packages(entries); + assert_eq!(paths.len(), 2); + assert!(paths[0].to_string_lossy().contains("26.10.1.0")); + } + + fn write_bundle(path: &Path, metadata: &str, sprite_name: &str, extra_sprite: bool) { + let map = format!("new URL(`{sprite_name}`,import.meta.url)"); + let mut entries = vec![ + ("codex-pet-assets-hash.js", map.as_bytes()), + ("app-initial-hash.js", metadata.as_bytes()), + (sprite_name, b"fixture-image".as_slice()), + ]; + if extra_sprite { + entries.push(("owl-spritesheet-extra.webp", b"other")); + } + let mut files = serde_json::Map::new(); + let mut content: Vec = Vec::new(); + for (name, bytes) in entries { + files.insert( + name.into(), + json!({"size":bytes.len(),"offset":content.len().to_string()}), + ); + content.extend(bytes); + } + let header = + serde_json::to_vec(&json!({"files":{"webview":{"files":{"assets":{"files":files}}}}})) + .unwrap(); + let payload = (header.len() + 7) & !3; + let mut bytes = Vec::new(); + for number in [4, (payload + 4) as u32, payload as u32, header.len() as u32] { + bytes.extend(number.to_le_bytes()); + } + bytes.extend(header); + bytes.resize(payload + 12, 0); + bytes.extend(content); + std::fs::write(path, bytes).unwrap(); + } + + #[test] + fn bundle_discovery_follows_current_asset_map_and_rejects_missing_or_changed_definitions() { + let temp = tempfile::tempdir().unwrap(); + let archive = temp.path().join("app.asar"); + let metadata = "[{assetRef:`owl`,displayName:`Owl`,id:`owl`,spriteVersionNumber:2}]"; + for name in ["owl-spritesheet-v5-old.webp", "owl-spritesheet-v6-new.webp"] { + write_bundle(&archive, metadata, name, true); + let pets = read_bundle(&archive, Some("owl")).unwrap(); + assert_eq!(pets.len(), 1); + assert!(pets[0].resource_path.ends_with(name)); + assert_eq!(pets[0].image, b"fixture-image"); + assert_eq!( + serde_json::from_slice::(&pets[0].manifest).unwrap()["spriteVersionNumber"], + 2 + ); + } + assert!(read_bundle(&archive, Some("removed")).is_err()); + write_bundle( + &archive, + &metadata.replace("spriteVersionNumber:2", "spriteVersionNumber:3"), + "owl-spritesheet.webp", + false, + ); + assert!(read_bundle(&archive, None).is_err()); + write_bundle(&archive, metadata, "other-spritesheet.webp", false); + assert!(read_bundle(&archive, None).is_err()); + } + #[test] + #[ignore = "Read-only smoke test against an installed Codex desktop app"] + fn installed_builtin_pet_catalog() { + let catalog = builtin_pet_sources(None); + assert!(catalog.diagnostics.is_empty(), "{:?}", catalog.diagnostics); + assert!(!catalog.pets.is_empty()); + for pet in &catalog.pets { + println!( + "{}: {} ({} bytes)", + pet.id, + pet.resource_path, + pet.image.len() + ); + } + } +} diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 64bf10f23e..31077af11b 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -68,6 +68,7 @@ use openbitfun_claude_code_adapter::{ ClaudeCodeCommandProvider, ClaudeCodeMcpProvider, ClaudeCodeSubagentProvider, }; use openbitfun_codex_adapter::{CodexMcpProvider, CodexSubagentProvider}; + use openbitfun_dsh_adapter::DshMcpProvider; use openbitfun_external_sources::{ DeferredDiscovery, ExternalMcpDiscoveryResult, ExternalSourceControlPlane, @@ -119,6 +120,28 @@ use tool_runtime::exec_command::{ exec_command_argv_for_isolated_shell, exec_command_noninteractive_env, ExecCommandShellKind, }; +pub use openbitfun_codex_adapter::BuiltinPetCatalog as ExternalBuiltinPetCatalog; + +pub fn external_builtin_pet_sources( + ecosystem_id: &str, + only_id: Option<&str>, +) -> ExternalBuiltinPetCatalog { + match ecosystem_id { + "codex" => openbitfun_codex_adapter::builtin_pet_sources(only_id), + _ => ExternalBuiltinPetCatalog { + pets: Vec::new(), + diagnostics: vec!["Built-in pet source is unsupported".into()], + }, + } +} + +pub fn external_pet_source_root(ecosystem_id: &str) -> Option { + match ecosystem_id { + "codex" => Some(openbitfun_codex_adapter::pet_source_root()), + _ => None, + } +} + const PROVIDER_DISCOVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); const EXTERNAL_SOURCE_PREFERENCES_FILE: &str = "external-sources.json"; const SUBAGENT_CONFLICT_RESELECTION_REQUIRED: &str = "__openbitfun_reselection_required__"; diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 6c967b0f4d..1110b496b3 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -83,6 +83,8 @@ target. Representative stable entry points are: ```bash cargo check -p openbitfun-services-core --no-default-features +cargo test -p openbitfun-services-core --no-default-features --features pet-packages --lib pet_packages +cargo test -p openbitfun-services-core --no-default-features --features asar --lib asar cargo test -p openbitfun-services-core --no-default-features --features process-runtime --lib system::info::tests cargo test -p openbitfun-services-core --no-default-features --features credential-vault --lib credential_vault::tests:: cargo check -p openbitfun-services-core --no-default-features --features filesystem diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index c8f42c12cd..a30c1ae867 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -11,6 +11,9 @@ name = "openbitfun_services_core" crate-type = ["rlib"] [dependencies] +image = { workspace = true, optional = true } +zip = { workspace = true, optional = true } +uuid = { workspace = true, features = ["v4"], optional = true } anyhow = { workspace = true, optional = true } aes-gcm = { workspace = true, optional = true } async-trait = { workspace = true, optional = true } @@ -49,7 +52,10 @@ windows = { workspace = true, optional = true } libc = { workspace = true, optional = true } [features] +asar = [] +installed-apps = ["dep:windows", "windows/Win32_Foundation", "windows/Win32_System_Registry"] default = [] +pet-packages = ["dep:image", "image/png", "image/webp", "image/jpeg", "image/gif", "dep:zip", "dep:uuid", "dep:sha2", "dep:base64", "dep:dunce", "dep:fs2"] workspace-persistence = ["workspace-identity", "dep:openbitfun-core-types", "dep:openbitfun-runtime-ports", "dep:chrono"] coordination-store = ["dep:rusqlite"] session-event-format = ["dep:openbitfun-events"] diff --git a/src/crates/services/services-core/src/asar.rs b/src/crates/services/services-core/src/asar.rs new file mode 100644 index 0000000000..321d25ff26 --- /dev/null +++ b/src/crates/services/services-core/src/asar.rs @@ -0,0 +1,160 @@ +//! Bounded, read-only access to packed Electron ASAR resources. Never executes bundle code. +use serde_json::Value; +use std::{ + fs::File, + io::{Read, Seek, SeekFrom}, + path::Path, +}; + +type Result = std::result::Result; + +pub struct AsarArchive { + file: File, + header: Value, + data_start: u64, + file_size: u64, +} + +impl AsarArchive { + pub fn open(path: &Path) -> Result { + let mut file = File::open(path).map_err(|e| e.to_string())?; + let metadata = file.metadata().map_err(|e| e.to_string())?; + if !metadata.is_file() { + return Err("ASAR archive must be a regular file".into()); + } + let file_size = metadata.len(); + let mut prefix = [0; 16]; + file.read_exact(&mut prefix).map_err(|e| e.to_string())?; + let number = |i| u32::from_le_bytes(prefix[i..i + 4].try_into().unwrap()) as u64; + let (header_size, payload_size, json_size) = (number(4), number(8), number(12)); + if number(0) != 4 + || header_size < 8 + || header_size > 16 * 1024 * 1024 + || payload_size + 4 != header_size + || json_size + 8 > header_size + || header_size + 8 > file_size + { + return Err("Invalid or oversized ASAR header".into()); + } + let mut bytes = vec![0; json_size as usize]; + file.read_exact(&mut bytes).map_err(|e| e.to_string())?; + let header: Value = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + if !header.get("files").is_some_and(Value::is_object) { + return Err("Missing ASAR file index".into()); + } + Ok(Self { + file, + header, + data_start: header_size + 8, + file_size, + }) + } + + fn entry(&self, path: &str) -> Result<&Value> { + let mut entry = &self.header; + for part in path.split('/') { + if part.is_empty() || part == "." || part == ".." || part.contains(['\\', ':']) { + return Err("Invalid ASAR resource path".into()); + } + entry = entry + .get("files") + .and_then(|v| v.get(part)) + .ok_or("ASAR resource is missing")?; + if entry.get("link").is_some() + || entry.get("unpacked").and_then(Value::as_bool) == Some(true) + { + return Err("Linked or unpacked ASAR resources are unsupported".into()); + } + } + Ok(entry) + } + + pub fn file_names(&self, directory: &str) -> Result> { + let entries = self + .entry(directory)? + .get("files") + .and_then(Value::as_object) + .ok_or("ASAR resource is not a directory")?; + if entries.len() > 20_000 { + return Err("ASAR directory entry limit reached".into()); + } + Ok(entries + .iter() + .filter(|(_, v)| v.get("files").is_none()) + .map(|(k, _)| k.clone()) + .collect()) + } + + pub fn read(&mut self, path: &str, limit: u64) -> Result> { + let entry = self.entry(path)?; + let size = entry + .get("size") + .and_then(Value::as_u64) + .ok_or("Invalid ASAR resource size")?; + let offset = entry + .get("offset") + .and_then(Value::as_str) + .and_then(|v| v.parse::().ok()) + .ok_or("Invalid ASAR resource offset")?; + let start = self + .data_start + .checked_add(offset) + .ok_or("ASAR offset overflow")?; + let end = start.checked_add(size).ok_or("ASAR size overflow")?; + if size > limit || end > self.file_size { + return Err("ASAR resource exceeds its bounds".into()); + } + self.file + .seek(SeekFrom::Start(start)) + .map_err(|e| e.to_string())?; + let mut bytes = vec![0; usize::try_from(size).map_err(|e| e.to_string())?]; + self.file + .read_exact(&mut bytes) + .map_err(|e| e.to_string())?; + Ok(bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture(path: &Path, entry: Value) { + let json = + serde_json::to_vec(&serde_json::json!({"files":{"assets":{"files":{"pet":entry}}}})) + .unwrap(); + let payload = (json.len() + 4 + 3) & !3; + let mut bytes = Vec::new(); + for value in [4, (payload + 4) as u32, payload as u32, json.len() as u32] { + bytes.extend(value.to_le_bytes()); + } + bytes.extend(json); + bytes.resize(payload + 12, 0); + bytes.extend(b"pet-image"); + std::fs::write(path, bytes).unwrap(); + } + #[test] + fn reads_only_bounded_packed_entries() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("app.asar"); + fixture(&path, serde_json::json!({"size":9,"offset":"0"})); + let mut archive = AsarArchive::open(&path).unwrap(); + assert_eq!(archive.file_names("assets").unwrap(), vec!["pet"]); + assert_eq!(archive.read("assets/pet", 9).unwrap(), b"pet-image"); + assert!(archive.read("assets/pet", 8).is_err()); + assert!(archive.read("assets/../pet", 9).is_err()); + for entry in [ + serde_json::json!({"size":10,"offset":"0"}), + serde_json::json!({"size":9,"offset":"18446744073709551615"}), + serde_json::json!({"size":9,"offset":"0","unpacked":true}), + serde_json::json!({"link":"../../private"}), + ] { + fixture(&path, entry); + assert!(AsarArchive::open(&path) + .unwrap() + .read("assets/pet", 32) + .is_err()); + } + std::fs::write(&path, [255; 16]).unwrap(); + assert!(AsarArchive::open(&path).is_err()); + } +} diff --git a/src/crates/services/services-core/src/installed_apps.rs b/src/crates/services/services-core/src/installed_apps.rs new file mode 100644 index 0000000000..85130a01be --- /dev/null +++ b/src/crates/services/services-core/src/installed_apps.rs @@ -0,0 +1,87 @@ +//! Read-only Windows per-user package registrations; no elevation or directory ownership changes. +use std::path::PathBuf; +use windows::{ + core::{w, PCWSTR, PWSTR}, + Win32::{ + Foundation::{ERROR_FILE_NOT_FOUND, ERROR_NO_MORE_ITEMS, ERROR_SUCCESS}, + System::Registry::{ + RegCloseKey, RegEnumKeyExW, RegGetValueW, RegOpenKeyExW, HKEY, HKEY_CURRENT_USER, + KEY_READ, RRF_RT_REG_SZ, + }, + }, +}; + +struct RegistryKey(HKEY); +impl Drop for RegistryKey { + fn drop(&mut self) { + unsafe { + let _ = RegCloseKey(self.0); + } + } +} + +pub fn windows_package_roots(package_name: &str) -> Result, String> { + let mut key = HKEY::default(); + let status = unsafe { + RegOpenKeyExW(HKEY_CURRENT_USER, + w!("Software\\Classes\\Local Settings\\Software\\Microsoft\\Windows\\CurrentVersion\\AppModel\\Repository\\Packages"), + None, KEY_READ, &mut key) + }; + if status == ERROR_FILE_NOT_FOUND { + return Ok(Vec::new()); + } + status.ok().map_err(|e| e.to_string())?; + let key = RegistryKey(key); + let mut paths = Vec::new(); + for index in 0..4096 { + let mut name = [0u16; 512]; + let mut count = name.len() as u32; + let status = unsafe { + RegEnumKeyExW( + key.0, + index, + Some(PWSTR(name.as_mut_ptr())), + &mut count, + None, + None, + None, + None, + ) + }; + if status == ERROR_NO_MORE_ITEMS { + return Ok(paths); + } + status.ok().map_err(|e| e.to_string())?; + let full_name = String::from_utf16(&name[..count as usize]).map_err(|e| e.to_string())?; + if !full_name.starts_with(&format!("{package_name}_")) { + continue; + } + let mut path = [0u16; 32768]; + let mut bytes = (path.len() * 2) as u32; + let status = unsafe { + RegGetValueW( + key.0, + PCWSTR(name.as_ptr()), + w!("PackageRootFolder"), + RRF_RT_REG_SZ, + None, + Some(path.as_mut_ptr().cast()), + Some(&mut bytes), + ) + }; + if status != ERROR_SUCCESS { + return Err(format!( + "Cannot read package root for {full_name}: {}", + status.0 + )); + } + let count = (bytes as usize / 2).min(path.len()); + let end = path[..count].iter().position(|v| *v == 0).unwrap_or(count); + let root = PathBuf::from(String::from_utf16(&path[..end]).map_err(|e| e.to_string())?); + if !root.is_absolute() { + return Err("Registered package path is not absolute".into()); + } + paths.push((full_name, root)); + } + Err("Installed package enumeration limit reached".into()) +} diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 2d50dc54d1..193aab3856 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -39,6 +39,8 @@ pub mod memory_store; pub mod permission_store; #[cfg(feature = "local-storage")] pub mod persistence; +#[cfg(feature = "pet-packages")] +pub mod pet_packages; #[cfg(feature = "process-runtime")] pub mod process_manager; #[cfg(feature = "process-runtime")] @@ -81,3 +83,9 @@ pub mod workspace_instructions; pub mod workspace_text; #[cfg(feature = "workspace-transfer")] pub mod workspace_transfer; + +#[cfg(feature = "asar")] +pub mod asar; + +#[cfg(all(feature = "installed-apps", target_os = "windows"))] +pub mod installed_apps; diff --git a/src/crates/services/services-core/src/pet_packages.rs b/src/crates/services/services-core/src/pet_packages.rs new file mode 100644 index 0000000000..fcf483644f --- /dev/null +++ b/src/crates/services/services-core/src/pet_packages.rs @@ -0,0 +1,757 @@ +//! Bounded local Petdex package IO. Hosts provide source and destination roots. +use base64::Engine; +use fs2::FileExt; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs, + io::{Cursor, Read}, + path::{Component, Path, PathBuf}, +}; + +const MANIFEST_LIMIT: u64 = 64 * 1024; +const IMAGE_LIMIT: u64 = 32 * 1024 * 1024; +const ARCHIVE_LIMIT: u64 = 64 * 1024 * 1024; +const RECEIPT: &str = ".openbitfun-import.json"; +const MAX_PACKAGES: usize = 128; +type Result = std::result::Result; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PetPackage { + pub id: String, + pub display_name: String, + pub description: Option, + pub source: String, + pub package_path: String, + pub spritesheet_path: String, + pub spritesheet_mime_type: String, + #[serde(default = "legacy_version")] + pub sprite_version_number: u32, +} +fn legacy_version() -> u32 { + 1 +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Receipt { + schema_version: u32, + source_key: String, + fingerprint: String, + installed_fingerprint: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PetCandidate { + pub source_key: String, + pub fingerprint: String, + pub pet: PetPackage, + pub preview_data_url: String, + pub imported: Option, + pub copy_modified: bool, + pub source_changed: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub builtin_id: Option, +} +#[derive(Default, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PetCatalog { + pub candidates: Vec, + pub diagnostics: Vec, +} + +struct Loaded { + manifest: serde_json::Value, + image: Vec, + name: String, + mime: String, + fingerprint: String, + preview: String, +} +fn hash(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} +fn source_key(path: &Path) -> Result { + let canonical = dunce::canonicalize(path).map_err(|e| e.to_string())?; + let identity = canonical.to_string_lossy().into_owned(); + #[cfg(windows)] + let identity = identity.to_lowercase(); + Ok(hash(identity.as_bytes())) +} +fn read_bounded(reader: impl Read, limit: u64) -> Result> { + let mut bytes = Vec::new(); + reader + .take(limit + 1) + .read_to_end(&mut bytes) + .map_err(|e| e.to_string())?; + if bytes.len() as u64 > limit { + return Err("Pet package resource exceeds its size limit".into()); + } + Ok(bytes) +} +fn read_file(path: &Path, limit: u64) -> Result> { + let meta = fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !meta.is_file() || meta.file_type().is_symlink() { + return Err("Pet resource must be a regular file".into()); + } + read_bounded(fs::File::open(path).map_err(|e| e.to_string())?, limit) +} +fn relative_resource(value: &str) -> Result { + // Interpret the portable manifest path consistently on every platform. + if value.contains(':') || value.contains('\\') { + return Err("Invalid pet resource path".into()); + } + let path = PathBuf::from(value); + if path.as_os_str().is_empty() + || path + .components() + .any(|c| !matches!(c, Component::Normal(_))) + { + return Err("Pet resource must stay inside its package".into()); + } + Ok(path) +} +fn manifest(bytes: &[u8]) -> Result<(serde_json::Value, PathBuf, u32)> { + let value: serde_json::Value = serde_json::from_slice(bytes).map_err(|e| e.to_string())?; + let version = match value.get("spriteVersionNumber") { + None => 1, + Some(v) => v + .as_u64() + .filter(|n| *n == 1 || *n == 2) + .ok_or("Unsupported pet sprite version")? as u32, + }; + let path = relative_resource( + value + .get("spritesheetPath") + .and_then(|v| v.as_str()) + .ok_or("Missing spritesheetPath")?, + )?; + Ok((value, path, version)) +} +fn load(path: &Path, preview: bool) -> Result { + let (manifest_bytes, image_bytes) = if path.is_dir() { + let root = dunce::canonicalize(path).map_err(|e| e.to_string())?; + let bytes = read_file(&root.join("pet.json"), MANIFEST_LIMIT)?; + let (_, relative, _) = manifest(&bytes)?; + let sprite = root.join(relative); + let resolved = dunce::canonicalize(&sprite).map_err(|e| e.to_string())?; + if !resolved.starts_with(&root) { + return Err("Pet resource escapes its package".into()); + } + (bytes, read_file(&sprite, IMAGE_LIMIT)?) + } else { + let bytes = read_file(path, ARCHIVE_LIMIT)?; + let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).map_err(|e| e.to_string())?; + if archive.len() > 256 { + return Err("Pet archive has too many entries".into()); + } + let mut manifests = Vec::new(); + for i in 0..archive.len() { + let entry = archive.by_index(i).map_err(|e| e.to_string())?; + let name = relative_resource(entry.name().trim_end_matches('/'))?; + if name.file_name().is_some_and(|n| n == "pet.json") { + manifests.push(entry.name().to_string()); + } + } + if manifests.len() != 1 { + return Err("Pet archive must contain exactly one pet.json".into()); + } + let name = &manifests[0]; + let bytes = read_bounded( + archive.by_name(name).map_err(|e| e.to_string())?, + MANIFEST_LIMIT, + )?; + let (_, relative, _) = manifest(&bytes)?; + let sprite = Path::new(name) + .parent() + .unwrap_or(Path::new("")) + .join(relative) + .to_string_lossy() + .replace('\\', "/"); + let image = read_bounded( + archive.by_name(&sprite).map_err(|e| e.to_string())?, + IMAGE_LIMIT, + )?; + (bytes, image) + }; + decode(&manifest_bytes, image_bytes, preview) +} + +fn decode(manifest_bytes: &[u8], image_bytes: Vec, preview: bool) -> Result { + if manifest_bytes.len() as u64 > MANIFEST_LIMIT || image_bytes.len() as u64 > IMAGE_LIMIT { + return Err("Pet package resource exceeds its size limit".into()); + } + let (value, resource, version) = manifest(manifest_bytes)?; + let extension = resource + .extension() + .and_then(|v| v.to_str()) + .ok_or("Missing pet image extension")?; + if !["png", "webp", "jpg", "jpeg", "gif"].contains(&extension.to_ascii_lowercase().as_str()) { + return Err("Unsupported pet image extension".into()); + } + let format = image::guess_format(&image_bytes).map_err(|e| e.to_string())?; + let mime = match format { + image::ImageFormat::Png => "image/png", + image::ImageFormat::WebP => "image/webp", + image::ImageFormat::Jpeg => "image/jpeg", + image::ImageFormat::Gif => "image/gif", + _ => return Err("Unsupported pet image format".into()), + }; + let reader = image::ImageReader::with_format(Cursor::new(&image_bytes), format); + let (width, height) = reader.into_dimensions().map_err(|e| e.to_string())?; + let rows = if version == 2 { 11 } else { 9 }; + if width == 0 + || height == 0 + || width % 8 != 0 + || height % rows != 0 + || u64::from(width) * u64::from(height) > 16_000_000 + { + return Err("Pet image dimensions do not match its sprite version".into()); + } + // Decode before accepting a package, including callers that do not request a preview. + let decoded = + image::load_from_memory_with_format(&image_bytes, format).map_err(|e| e.to_string())?; + let preview = if preview { + let thumb = decoded + .crop_imm(0, 0, width / 8, height / rows) + .thumbnail(96, 104); + let mut png = Cursor::new(Vec::new()); + thumb + .write_to(&mut png, image::ImageFormat::Png) + .map_err(|e| e.to_string())?; + format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(png.into_inner()) + ) + } else { + String::new() + }; + let mut digest = Sha256::new(); + digest.update((manifest_bytes.len() as u64).to_le_bytes()); + digest.update(&manifest_bytes); + digest.update(&image_bytes); + Ok(Loaded { + manifest: value, + image: image_bytes, + name: resource.file_name().unwrap().to_string_lossy().into_owned(), + mime: mime.into(), + fingerprint: format!("{:x}", digest.finalize()), + preview, + }) +} +fn metadata(dir: &Path, loaded: &Loaded, source: &str) -> PetPackage { + let raw = loaded + .manifest + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("custom-pet"); + let id: String = raw + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c.to_ascii_lowercase() + } else { + '-' + } + }) + .collect(); + let trimmed_id = id.trim_matches('-'); + PetPackage { + id: if trimmed_id.is_empty() { + "custom-pet".into() + } else { + trimmed_id.into() + }, + display_name: loaded + .manifest + .get("displayName") + .and_then(|v| v.as_str()) + .filter(|v| !v.trim().is_empty()) + .unwrap_or(raw) + .trim() + .to_string(), + description: loaded + .manifest + .get("description") + .and_then(|v| v.as_str()) + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()), + source: source.into(), + package_path: dir.to_string_lossy().into_owned(), + spritesheet_path: dir + .join( + loaded.manifest["spritesheetPath"] + .as_str() + .unwrap_or(&loaded.name), + ) + .to_string_lossy() + .into_owned(), + spritesheet_mime_type: loaded.mime.clone(), + sprite_version_number: loaded + .manifest + .get("spriteVersionNumber") + .and_then(|v| v.as_u64()) + .unwrap_or(1) as u32, + } +} +fn directories(root: &Path) -> Result> { + if !root.exists() { + return Ok(Vec::new()); + } + let mut paths = Vec::new(); + let entries = fs::read_dir(root).map_err(|e| e.to_string())?; + for (index, entry) in entries.enumerate() { + if index >= 4096 { + return Err("Pet directory entry limit reached".into()); + } + let entry = entry.map_err(|e| e.to_string())?; + if entry.file_name().to_string_lossy().starts_with('.') { + continue; + } + if entry.file_type().map_err(|e| e.to_string())?.is_dir() { + paths.push(entry.path()); + } + } + if paths.len() > MAX_PACKAGES { + return Err("Pet package limit reached".into()); + } + paths.sort(); + Ok(paths) +} + +/// Validate a reviewed source again, then publish an independent copy under a root lock. +/// Older callers may omit the fingerprint; ecosystem callers must supply the catalog value. +pub fn import(root: &Path, source: &Path, expected: Option<&str>) -> Result { + publish(root, load(source, false)?, source_key(source)?, expected) +} + +/// Import a resource supplied by a static source adapter, with the same receipt and review rules. +pub fn import_bytes( + root: &Path, + identity: &str, + manifest: &[u8], + image: Vec, + expected: &str, +) -> Result { + publish( + root, + decode(manifest, image, false)?, + hash(identity.as_bytes()), + Some(expected), + ) +} + +fn publish( + root: &Path, + mut loaded: Loaded, + key: String, + expected: Option<&str>, +) -> Result { + if expected.is_some_and(|value| value != loaded.fingerprint) { + return Err("Pet source changed; refresh and review again".into()); + } + fs::create_dir_all(root).map_err(|e| e.to_string())?; + let root = dunce::canonicalize(root).map_err(|e| e.to_string())?; + let lock = fs::OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(root.join(".import.lock")) + .map_err(|e| e.to_string())?; + FileExt::lock_exclusive(&lock).map_err(|e| e.to_string())?; + let installed = directories(&root)?; + for dir in &installed { + if dir.join(RECEIPT).exists() { + let bytes = read_file(&dir.join(RECEIPT), MANIFEST_LIMIT)?; + let receipt: Receipt = serde_json::from_slice(&bytes).map_err(|e| e.to_string())?; + if receipt.schema_version != 1 { + return Err("Unsupported pet import receipt".into()); + } + if receipt.source_key == key { + // Never replace an edited copy or create duplicates for the same source. + return load(dir, false).map(|current| metadata(dir, ¤t, "user")); + } + } + } + if installed.len() >= MAX_PACKAGES { + return Err("Pet package limit reached".into()); + } + let fingerprint = loaded.fingerprint.clone(); + loaded.manifest["spritesheetPath"] = serde_json::Value::String(loaded.name.clone()); + let staging = root.join(format!(".staging-{}", uuid::Uuid::new_v4().simple())); + let destination = root.join(format!("pet-{}", uuid::Uuid::new_v4().simple())); + fs::create_dir(&staging).map_err(|e| e.to_string())?; + let publish = (|| { + fs::write( + staging.join("pet.json"), + serde_json::to_vec_pretty(&loaded.manifest).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + fs::write(staging.join(&loaded.name), &loaded.image).map_err(|e| e.to_string())?; + let installed = load(&staging, false)?; + let receipt = Receipt { + schema_version: 1, + source_key: key, + fingerprint, + installed_fingerprint: installed.fingerprint.clone(), + }; + fs::write( + staging.join(RECEIPT), + serde_json::to_vec(&receipt).map_err(|e| e.to_string())?, + ) + .map_err(|e| e.to_string())?; + fs::rename(&staging, &destination).map_err(|e| e.to_string())?; + Ok(metadata(&destination, &installed, "user")) + })(); + if publish.is_err() { + let _ = fs::remove_dir_all(&staging); + } + publish +} + +pub fn catalog(source_root: &Path, installed_root: &Path) -> PetCatalog { + let mut report = PetCatalog::default(); + let installed = match directories(installed_root) { + Ok(v) => v, + Err(e) => { + report + .diagnostics + .push(format!("Could not inspect installed pets: {e}")); + return report; + } + }; + let sources = match directories(source_root) { + Ok(v) => v, + Err(e) => { + report + .diagnostics + .push(format!("Could not inspect pet sources: {e}")); + return report; + } + }; + for dir in sources { + let result: Result = (|| { + let loaded = load(&dir, true)?; + let key = source_key(&dir)?; + candidate(&installed, &dir, key, loaded) + })(); + match result { + Ok(candidate) => report.candidates.push(candidate), + Err(e) => report.diagnostics.push(format!("{}: {e}", dir.display())), + } + } + report +} + +/// Build a preview and reconcile an adapter-provided resource without writing a package. +pub fn candidate_from_bytes( + installed_root: &Path, + identity: &str, + origin: &Path, + manifest: &[u8], + image: Vec, +) -> Result { + candidate( + &directories(installed_root)?, + origin, + hash(identity.as_bytes()), + decode(manifest, image, true)?, + ) +} + +fn candidate( + installed: &[PathBuf], + dir: &Path, + key: String, + loaded: Loaded, +) -> Result { + let mut candidate = PetCandidate { + source_key: key.clone(), + fingerprint: loaded.fingerprint.clone(), + pet: metadata(&dir, &loaded, "codex"), + preview_data_url: loaded.preview.clone(), + imported: None, + copy_modified: false, + source_changed: false, + builtin_id: None, + }; + for native in installed { + if !native.join(RECEIPT).exists() { + continue; + } + let receipt: Receipt = + serde_json::from_slice(&read_file(&native.join(RECEIPT), MANIFEST_LIMIT)?) + .map_err(|e| e.to_string())?; + if receipt.schema_version != 1 { + return Err("Unsupported pet import receipt".into()); + } + if receipt.source_key == key { + let current = load(native, false)?; + candidate.copy_modified = current.fingerprint != receipt.installed_fingerprint; + candidate.source_changed = loaded.fingerprint != receipt.fingerprint; + candidate.imported = Some(metadata(native, ¤t, "user")); + break; + } + } + Ok(candidate) +} + +#[cfg(test)] +mod tests { + use super::*; + fn fixture(root: &Path, name: &str, version: Option) -> PathBuf { + let dir = root.join(name); + fs::create_dir_all(&dir).unwrap(); + let mut value = + serde_json::json!({"id":name,"displayName":name,"spritesheetPath":"sprite.png"}); + if let Some(version) = version { + value["spriteVersionNumber"] = version.into(); + } + fs::write(dir.join("pet.json"), serde_json::to_vec(&value).unwrap()).unwrap(); + let rows = if version == Some(2) { 11 } else { 9 }; + image::RgbaImage::new(16, rows * 2) + .save(dir.join("sprite.png")) + .unwrap(); + dir + } + + #[test] + fn bundled_identity_survives_app_updates_and_reconciles_native_deletion() { + let temp = tempfile::tempdir().unwrap(); + let fixture = fixture(temp.path(), "owl", Some(2)); + let manifest = fs::read(fixture.join("pet.json")).unwrap(); + let image = fs::read(fixture.join("sprite.png")).unwrap(); + let native = temp.path().join("native"); + let identity = "codex:builtin:owl"; + let reviewed = candidate_from_bytes( + &native, + identity, + Path::new("old/app.asar"), + &manifest, + image.clone(), + ) + .unwrap(); + assert!(!native.exists()); + let pet = import_bytes( + &native, + identity, + &manifest, + image.clone(), + &reviewed.fingerprint, + ) + .unwrap(); + let moved = candidate_from_bytes( + &native, + identity, + Path::new("new/app.asar"), + &manifest, + image.clone(), + ) + .unwrap(); + assert_eq!(moved.source_key, reviewed.source_key); + assert_eq!(moved.imported.unwrap().package_path, pet.package_path); + assert_eq!( + import_bytes( + &native, + identity, + &manifest, + image.clone(), + &reviewed.fingerprint + ) + .unwrap() + .package_path, + pet.package_path + ); + let mut changed: serde_json::Value = serde_json::from_slice(&manifest).unwrap(); + changed["displayName"] = "Updated owl".into(); + let changed = serde_json::to_vec(&changed).unwrap(); + assert!(import_bytes( + &native, + identity, + &changed, + image.clone(), + &reviewed.fingerprint + ) + .is_err()); + let changed_source = candidate_from_bytes( + &native, + identity, + Path::new("new/app.asar"), + &changed, + image.clone(), + ) + .unwrap(); + assert!(changed_source.source_changed); + fs::remove_dir_all(&pet.package_path).unwrap(); + assert!(candidate_from_bytes( + &native, + identity, + Path::new("new/app.asar"), + &manifest, + image + ) + .unwrap() + .imported + .is_none()); + } + #[test] + fn imports_both_versions_and_legacy_payload_and_rejects_stale_review() { + let temp = tempfile::tempdir().unwrap(); + for version in [None, Some(1), Some(2)] { + let src = fixture( + &temp.path().join("source"), + &format!("pet{version:?}"), + version, + ); + let reviewed = load(&src, true).unwrap(); + assert!(reviewed.preview.starts_with("data:image/png;base64,")); + assert!(import(&temp.path().join("native"), &src, Some("stale")).is_err()); + let pet = import( + &temp.path().join("native"), + &src, + Some(&reviewed.fingerprint), + ) + .unwrap(); + assert_eq!(pet.sprite_version_number, version.unwrap_or(1)); + let mut old = serde_json::to_value(&pet).unwrap(); + old.as_object_mut().unwrap().remove("spriteVersionNumber"); + let restored: PetPackage = serde_json::from_value(old).unwrap(); + assert_eq!(restored.sprite_version_number, 1); + let round_trip: PetPackage = + serde_json::from_value(serde_json::to_value(restored).unwrap()).unwrap(); + assert_eq!(round_trip.package_path, pet.package_path); + } + } + #[test] + fn concurrent_imports_publish_one_copy_and_keep_unknown_receipts() { + let temp = tempfile::tempdir().unwrap(); + let source = fixture(&temp.path().join("sources"), "cat", Some(2)); + let native = temp.path().join("native"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let threads: Vec<_> = (0..2) + .map(|_| { + let (source, native, barrier) = (source.clone(), native.clone(), barrier.clone()); + std::thread::spawn(move || { + barrier.wait(); + import(&native, &source, None).unwrap() + }) + }) + .collect(); + let pets: Vec<_> = threads.into_iter().map(|t| t.join().unwrap()).collect(); + assert_eq!(pets[0].package_path, pets[1].package_path); + assert_eq!(directories(&native).unwrap().len(), 1); + let receipt = Path::new(&pets[0].package_path).join(RECEIPT); + fs::write(&receipt, b"unknown receipt").unwrap(); + assert!(import(&native, &source, None).is_err()); + assert_eq!(fs::read(&receipt).unwrap(), b"unknown receipt"); + assert_eq!(directories(&native).unwrap().len(), 1); + assert!(!catalog(&temp.path().join("sources"), &native) + .diagnostics + .is_empty()); + } + + #[test] + fn repeated_import_is_idempotent_and_delete_restores_candidate() { + let temp = tempfile::tempdir().unwrap(); + let sources = temp.path().join("sources"); + let native = temp.path().join("native"); + let src = fixture(&sources, "cat", Some(2)); + let first = import(&native, &src, None).unwrap(); + let second = import(&native, &src, None).unwrap(); + assert_eq!(first.package_path, second.package_path); + assert!(catalog(&sources, &native).candidates[0].imported.is_some()); + fs::remove_dir_all(&first.package_path).unwrap(); + assert!(catalog(&sources, &native).candidates[0].imported.is_none()); + let next = import(&native, &src, None).unwrap(); + assert_ne!(first.package_path, next.package_path); + } + #[test] + fn keeps_edited_copies_and_reports_source_changes() { + let temp = tempfile::tempdir().unwrap(); + let sources = temp.path().join("sources"); + let native = temp.path().join("native"); + let src = fixture(&sources, "cat", None); + let pet = import(&native, &src, None).unwrap(); + for dir in [&src, &PathBuf::from(&pet.package_path)] { + let manifest_path = dir.join("pet.json"); + let mut value: serde_json::Value = + serde_json::from_slice(&fs::read(&manifest_path).unwrap()).unwrap(); + value["displayName"] = "edited".into(); + fs::write(manifest_path, serde_json::to_vec(&value).unwrap()).unwrap(); + } + let report = catalog(&sources, &native); + assert!(report.candidates[0].copy_modified); + assert!(report.candidates[0].source_changed); + assert_eq!( + import(&native, &src, None).unwrap().package_path, + pet.package_path + ); + } + #[test] + fn rejects_escaping_paths_bad_images_and_unknown_versions() { + let temp = tempfile::tempdir().unwrap(); + let src = fixture(temp.path(), "pet", None); + for path in [ + "../sprite.png", + "/sprite.png", + "C:/sprite.png", + "folder\\sprite.png", + ".openbitfun-import.json", + ] { + fs::write( + src.join("pet.json"), + serde_json::to_vec(&serde_json::json!({"spritesheetPath":path})).unwrap(), + ) + .unwrap(); + assert!(load(&src, false).is_err(), "{path}"); + } + fixture(temp.path(), "pet", Some(3)); + assert!(load(&src, false).is_err()); + fixture(temp.path(), "pet", None); + image::RgbaImage::new(17, 18) + .save(src.join("sprite.png")) + .unwrap(); + assert!(load(&src, false).is_err()); + fs::write(src.join("sprite.png"), b"not an image").unwrap(); + assert!(load(&src, false).is_err()); + } + #[test] + fn rejects_oversize_manifests_and_reports_invalid_sources() { + let temp = tempfile::tempdir().unwrap(); + let src = fixture(temp.path(), "pet", None); + fs::write( + src.join("pet.json"), + vec![b' '; MANIFEST_LIMIT as usize + 1], + ) + .unwrap(); + assert!(load(&src, false).is_err()); + let report = catalog(temp.path(), &temp.path().join("missing")); + assert!(report.candidates.is_empty()); + assert!(!report.diagnostics.is_empty()); + } + #[test] + fn accepts_nested_zip_packages_and_normalizes_resource_location() { + use std::io::Write; + let temp = tempfile::tempdir().unwrap(); + let src = fixture(temp.path(), "source", Some(2)); + let archive = temp.path().join("pet.zip"); + let mut zip = zip::ZipWriter::new(fs::File::create(&archive).unwrap()); + let options = zip::write::SimpleFileOptions::default(); + zip.start_file("package/pet.json", options).unwrap(); + zip.write_all( + br#"{"id":"zip","spritesheetPath":"assets/sprite.png","spriteVersionNumber":2}"#, + ) + .unwrap(); + zip.start_file("package/assets/sprite.png", options) + .unwrap(); + zip.write_all(&fs::read(src.join("sprite.png")).unwrap()) + .unwrap(); + zip.finish().unwrap(); + let pet = import(&temp.path().join("native"), &archive, None).unwrap(); + assert!(Path::new(&pet.spritesheet_path).is_file()); + assert_eq!(pet.sprite_version_number, 2); + } +} diff --git a/src/mobile-web/tests/browser-account.test.mjs b/src/mobile-web/tests/browser-account.test.mjs index 9ef7c7141a..99557626f4 100644 --- a/src/mobile-web/tests/browser-account.test.mjs +++ b/src/mobile-web/tests/browser-account.test.mjs @@ -3,7 +3,7 @@ import { after, before, test } from 'node:test'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; -import { LAN, OFFICIAL, RelayFixture, connected, invitation, launchBrowser, readAccount, +import { LAN, OFFICIAL, RelayFixture, connected, disconnect, invitation, launchBrowser, readAccount, signIn, signOut, startSourceServer, until } from './helpers/browser-account-harness.mjs'; let browser; @@ -134,21 +134,7 @@ test('disconnect affects only the current tab and remains disconnected after rel await signIn(first); await connected(first); const second = await relay.page(context, source.origin); await connected(second); - await first.bringToFront(); - await first.click('.harmony-sidebar__settings'); - await first.waitForSelector('.harmony-sidebar__settings-disconnect', { visible: true }); - await first.waitForFunction(() => { - const sheet = document.querySelector('.harmony-sidebar__settings-disconnect')?.closest('[role="dialog"]'); - return sheet && sheet.getBoundingClientRect().bottom <= innerHeight + 1; - }, { polling: 100 }); - await first.$eval('.harmony-sidebar__settings-disconnect', button => button.scrollIntoView({ block: 'center' })); - await first.click('.harmony-sidebar__settings-disconnect'); - await first.waitForFunction(() => [...document.querySelectorAll('[role="dialog"]')] - .some(dialog => dialog.textContent.includes('Disconnect this tab')), { polling: 100 }); - await first.evaluate(() => [...document.querySelectorAll('[role="dialog"]')] - .find(dialog => dialog.textContent.includes('Disconnect this tab')) - .querySelector('button[data-appearance="danger"]').click()); - await first.waitForSelector('.devices-page__description'); + await disconnect(first); await first.reload(); await first.waitForSelector('.devices-page__description'); assert.equal(await first.evaluate(async () => (await import('/src/services/store.ts')).useMobileStore.getState().controlTarget), null); diff --git a/src/mobile-web/tests/helpers/browser-account-harness.mjs b/src/mobile-web/tests/helpers/browser-account-harness.mjs index 95077413d6..69f88f40b9 100644 --- a/src/mobile-web/tests/helpers/browser-account-harness.mjs +++ b/src/mobile-web/tests/helpers/browser-account-harness.mjs @@ -272,6 +272,24 @@ export async function connected(page, device = 'desktop-a') { assert.equal(await page.$('.pairing-page__form'), null); } +export async function disconnect(page) { + await page.bringToFront(); + await page.click('.harmony-sidebar__settings'); + await page.waitForSelector('.harmony-sidebar__settings-disconnect', { visible: true }); + await page.waitForFunction(() => { + const sheet = document.querySelector('.harmony-sidebar__settings-disconnect')?.closest('[role="dialog"]'); + return sheet && sheet.getBoundingClientRect().bottom <= innerHeight + 1; + }, { polling: 100 }); + await page.$eval('.harmony-sidebar__settings-disconnect', button => button.scrollIntoView({ block: 'center' })); + await page.click('.harmony-sidebar__settings-disconnect'); + await page.waitForFunction(() => [...document.querySelectorAll('[role="dialog"]')] + .some(dialog => dialog.textContent.includes('Disconnect this tab')), { polling: 100 }); + await page.evaluate(() => [...document.querySelectorAll('[role="dialog"]')] + .find(dialog => dialog.textContent.includes('Disconnect this tab')) + .querySelector('button[data-appearance="danger"]').click()); + await page.waitForSelector('.devices-page__description'); +} + export async function signOut(page) { await page.bringToFront(); // Open the device directory from the UI if this tab is controlling a host. diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.discovery.test.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.discovery.test.tsx index 2fd2620d41..eafd66202f 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.discovery.test.tsx +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.discovery.test.tsx @@ -51,8 +51,15 @@ vi.mock('@openbitfun/ui', async () => { }; }); +vi.mock('@/infrastructure/config/services/AgentCompanionPetService', () => ({ + listExternalAgentCompanionPets: async () => ({ candidates: [], diagnostics: [] }), + AGENT_COMPANION_PETS_CHANGED: 'agent-companion-pets-changed', +})); +vi.mock('@/infrastructure/config/services/AIExperienceConfigService', () => ({ aiExperienceConfigService: { + addChangeListener: () => () => {}, +} })); vi.mock('@/infrastructure/runtime', () => ({ isTauriRuntime: () => true })); -vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ configAPI: { getSkillScanReport: async () => ({ skills: mocks.skills, diagnostics: [] }) } })); +vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ configAPI: { getConfig: async () => ({ enable_agent_companion: false }), getSkillScanReport: async () => ({ skills: mocks.skills, diagnostics: [] }) } })); vi.mock('@/infrastructure/api/service-api/ExternalHooksAPI', () => ({ externalHooksAPI: { getCatalog: async () => ({ sources: [], entries: [], providers: [], failedProviderIds: [], discoveryPending: false }), getImportSnapshot: async () => ({ catalog: { sources: [], entries: [], providers: [], failedProviderIds: [], discoveryPending: false }, imports: [] }), diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts index ba22d464b0..49bc5ca4ef 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.presentation.test.ts @@ -197,8 +197,8 @@ describe('ecosystem compatibility scene presentation contract', () => { const items = buildEcosystemImportItems(null, runtime!); expect(items.map((item) => item.kind)).toEqual([ - 'subagent', 'skill', 'mcp', 'hook', 'instruction', - 'account', 'settings', 'memory', 'plugin', 'pet', + 'subagent', 'skill', 'mcp', 'hook', 'instruction', 'pet', + 'account', 'settings', 'memory', 'plugin', ]); expect(items.every((item) => item.discovered === false)).toBe(true); expect(items.every((item) => item.candidateId === undefined)).toBe(true); @@ -213,7 +213,7 @@ describe('ecosystem compatibility scene presentation contract', () => { detection: 'owner', })); expect(items.find((item) => item.kind === 'memory')?.discoverySupport).toBe('unsupported'); - expect(items.find((item) => item.kind === 'pet')?.discoverySupport).toBe('unsupported'); + expect(items.find((item) => item.kind === 'pet')?.discoverySupport).toBe('supported'); }); it('uses product-specific compatibility instead of one shared capability set', () => { @@ -248,7 +248,7 @@ describe('ecosystem compatibility scene presentation contract', () => { const codexSupport = support('codex'); expect(codexSupport).toMatchObject({ memory: 'unsupported', - pet: 'unsupported', + pet: 'supported', }); expect(codexSupport).not.toHaveProperty('command'); expect(codexSupport).not.toHaveProperty('tool'); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss index 628f8804e6..ac9166094c 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemCompatibilityScene.scss @@ -915,6 +915,32 @@ gap: var(--openbitfun-space-4); } + &__pet-list { + display: grid; + gap: var(--openbitfun-space-3); + } + + &__pet-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: var(--openbitfun-space-3); + } + + &__pet-actions { + display: flex; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; + gap: var(--openbitfun-space-2); + } + + &__pet-preview { + inline-size: 4rem; + block-size: 4.5rem; + object-fit: contain; + } + &__account-panel { display: grid; grid-template-columns: minmax(0, 1fr) auto; @@ -1013,6 +1039,11 @@ } @container ecosystem-content (max-width: 520px) { + .ecosystem-compatibility__pet-row { + grid-template-columns: auto minmax(0, 1fr); + > :last-child { grid-column: 1 / -1; justify-self: end; } + } + .ecosystem-compatibility__account-panel { grid-template-columns: minmax(0, 1fr); } diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemPets.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemPets.tsx new file mode 100644 index 0000000000..a3a059427c --- /dev/null +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/EcosystemPets.tsx @@ -0,0 +1,153 @@ +import { useEffect, useId, useState } from 'react'; +import { Alert, Button, Card, CardHeader, DialogBody, DialogClose, DialogDescription, DialogFooter, DialogHeader, DialogHeaderActions, DialogHeading, DialogTitle, Icon, IconButton, LoadingState, OverflowText, ScrollArea, StatusPill } from '@openbitfun/ui'; +import { PawPrint } from 'lucide-react'; +import { EcosystemDialog as Dialog } from './EcosystemDialog'; +import { useI18n } from '@/infrastructure/i18n'; +import { globalEventBus } from '@/infrastructure/event-bus'; +import { getActiveSurfaceScope } from '@/infrastructure/peer-device/deviceSurface'; +import { AGENT_COMPANION_PETS_CHANGED, importReviewedAgentCompanionPet, listExternalAgentCompanionPets, type ExternalPetCandidate, type ExternalPetCatalog } from '@/infrastructure/config/services/AgentCompanionPetService'; +import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; +import { aiExperienceConfigService } from '@/infrastructure/config/services/AIExperienceConfigService'; +import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; +import { useSceneStore } from '@/app/stores/sceneStore'; + +interface Props { supported: boolean; refreshVersion: number; open: boolean; onOpenChange: (open: boolean) => void; onCountChange?: (count: number) => void } + +export default function EcosystemPets({ supported, refreshVersion, open, onOpenChange, onCountChange }: Props) { + const { t, formatNumber } = useI18n('scenes/ecosystem-compatibility'); + const id = useId(); + const [revision, setRevision] = useState(0); + const [catalog, setCatalog] = useState(null); + const [selectedPath, setSelectedPath] = useState(null); + const [loading, setLoading] = useState(false); + const [failed, setFailed] = useState(false); + const [busy, setBusy] = useState(false); + const [review, setReview] = useState(null); + const [feedback, setFeedback] = useState(null); + useEffect(() => { + let current = true; + setFailed(false); + if (!supported) { setCatalog(null); setSelectedPath(null); setReview(null); setLoading(false); return; } + const scope = getActiveSurfaceScope(); + setLoading(true); + void Promise.all([listExternalAgentCompanionPets(), configAPI.getConfig('app.ai_experience')]).then(([next, settings]) => { + if (!current || !scope.isCurrent()) return; + setCatalog(next); + setSelectedPath(settings?.enable_agent_companion ? settings.agent_companion_pet?.packagePath ?? null : null); + }).catch(() => { if (current && scope.isCurrent()) setFailed(true); }) + .finally(() => { if (current && scope.isCurrent()) setLoading(false); }); + return () => { current = false; }; + }, [supported, refreshVersion, revision]); + useEffect(() => { + if (!supported) return; + const refresh = () => setRevision((value) => value + 1); + const off = globalEventBus.on(AGENT_COMPANION_PETS_CHANGED, refresh); + const scope = getActiveSurfaceScope(); + const stop = aiExperienceConfigService.addChangeListener((settings) => { + if (scope.isCurrent()) setSelectedPath(settings.enable_agent_companion ? settings.agent_companion_pet?.packagePath ?? null : null); + }); + return () => { off(); stop(); }; + }, [supported]); + useEffect(() => { + onCountChange?.(supported ? catalog?.candidates.length ?? 0 : 0); + }, [supported, catalog, onCountChange]); + const entries = supported ? catalog?.candidates ?? [] : []; + const state = !supported ? 'unsupported' : loading ? 'loading' : failed ? 'failed' + : catalog?.diagnostics.length ? 'partial' : entries.length ? 'available' : 'empty'; + const manage = () => { + if (!supported) return; + onOpenChange(false); + useSettingsStore.getState().openDestination({ pageId: 'application.pet' }); + useSceneStore.getState().openScene('settings'); + }; + const apply = async () => { + if (!supported || !review || busy) return; + const scope = getActiveSurfaceScope(); + setBusy(true); setFeedback(null); + try { + await importReviewedAgentCompanionPet(review); + if (!scope.isCurrent()) return; + setReview(null); setRevision((v) => v + 1); + } catch { + if (scope.isCurrent()) { setReview(null); setFeedback(t('content.pets.operationFailed')); setRevision((v) => v + 1); } + } finally { setBusy(false); } + }; + const activatePet = async (candidate: ExternalPetCandidate) => { + if (!supported || busy) return; + const scope = getActiveSurfaceScope(); + setBusy(true); setFeedback(null); + try { + const latest = await listExternalAgentCompanionPets(); + if (!scope.isCurrent()) return; + const copy = latest.candidates.find((entry) => entry.sourceKey === candidate.sourceKey)?.imported; + if (!copy || copy.packagePath !== candidate.imported?.packagePath) throw new Error('Pet copy changed'); + await aiExperienceConfigService.saveSettings({ agent_companion_pet: copy, enable_agent_companion: true }); + if (scope.isCurrent()) { setCatalog(latest); setSelectedPath(copy.packagePath); } + } catch { if (scope.isCurrent()) { setFeedback(t('content.pets.operationFailed')); setRevision((v) => v + 1); } } + finally { setBusy(false); } + }; + return
+
+ + + {t('capabilities.pet')}{t('content.pets.description')} + + Codex + + {state === 'available' ? t('content.itemCount', { count: formatNumber(entries.length) }) : state === 'loading' ? t('loading') : t(`content.pets.states.${state}`)} + } aria-haspopup="dialog" aria-controls={id} + aria-label={t('content.viewCategory', { type: t('capabilities.pet') })} + onClick={() => onOpenChange(true)} /> + +
+ { if (!busy && !review) onOpenChange(next); }} size="xl" + closeOnEscape={!busy && !review} closeOnPointerOutside={!busy && !review}> + + + Codex · {t('capabilities.pet')} + {t('content.groupSummary', { count: formatNumber(entries.length) })} + + + } aria-label={t('content.refresh')} title={t('content.refresh')} + disabled={!supported || loading || busy} onClick={() => setRevision((v) => v + 1)} /> + {!busy ? : null} + + + + +
+ {loading && supported ? {t('loading')} : null} + {feedback && supported ? : null} + {['unsupported', 'failed', 'empty'].includes(state) ? : null} + {supported && catalog?.diagnostics.length ? : null} + {entries.map((candidate) => { + const using = !!candidate.imported && candidate.imported.packagePath === selectedPath; + const status = candidate.copyModified ? 'copyModified' : candidate.sourceChanged ? 'sourceChanged' : using ? 'using' : candidate.imported ? 'imported' : 'ready'; + return +
+ {candidate.pet.displayName} + {candidate.pet.displayName}} description={{t(candidate.builtinId ? 'content.pets.builtinSource' : 'content.pets.customSource')} · {candidate.pet.packagePath}} /> +
+ {t(`content.pets.states.${status}`)} + {candidate.imported ? <> + + : } +
+
+
; + })} +
+
+
+
+ { if (!open && !busy) setReview(null); }}> + {t('content.pets.reviewTitle')} + {review && supported ?
+ {review.pet.displayName} + {review.pet.displayName}

{t(review.builtinId ? 'content.pets.builtinSource' : 'content.pets.customSource')}

{review.pet.packagePath}

{t('content.pets.reviewDescription')}

+
: null}
+ +
+
; +} diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.test.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.test.tsx index c0f8829235..1287747e3d 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.test.tsx +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.test.tsx @@ -8,7 +8,7 @@ import type { ExternalSourceCatalogSnapshot } from '@/infrastructure/api/service import { buildEcosystemProductRuntimes, type EcosystemProductId } from './ecosystemCompatibilityModel'; const mocks = vi.hoisted(() => ({ - getAccounts: vi.fn(), getInstructions: vi.fn(), openScene: vi.fn(), openDestination: vi.fn(), openNativeSkills: vi.fn(), + petSettingsListener: vi.fn(), getAccounts: vi.fn(), getPets: vi.fn(), importPet: vi.fn(), getPetSettings: vi.fn(), savePetSettings: vi.fn(), getInstructions: vi.fn(), openScene: vi.fn(), openDestination: vi.fn(), openNativeSkills: vi.fn(), deleteSkill: vi.fn(), loadMcp: vi.fn(), saveMcp: vi.fn(), mutateHook: vi.fn(), getSkills: vi.fn(), validateSkill: vi.fn(), addSkill: vi.fn(), getHooks: vi.fn(), getHookCatalog: vi.fn(), planHook: vi.fn(), applyHook: vi.fn(), planMcp: vi.fn(), applyMcp: vi.fn(), refresh: vi.fn(), workspacePath: '/project', remote: false, peer: false, skillImportVersion: 0, @@ -23,6 +23,7 @@ vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspa vi.mock('@/infrastructure/peer-device/peerDeviceContextState', () => ({ usePeerDeviceModeOptional: () => ({ peerMode: { active: mocks.peer } }) })); vi.mock('@/infrastructure/runtime', () => ({ isTauriRuntime: () => true })); vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ configAPI: { + getConfig: mocks.getPetSettings, getSkillScanReport: async (...args: unknown[]) => ({ skills: await mocks.getSkills(...args), diagnostics: [], importOperationsVersion: mocks.skillImportVersion }), validateSkillPath: mocks.validateSkill, addSkill: mocks.addSkill, deleteSkill: mocks.deleteSkill, } })); vi.mock('@/infrastructure/api/service-api/ExternalHooksAPI', () => ({ externalHooksAPI: { @@ -31,6 +32,13 @@ vi.mock('@/infrastructure/api/service-api/ExternalHooksAPI', () => ({ externalHo vi.mock('@/infrastructure/api/service-api/ExternalSourcesAPI', () => ({ externalSourcesAPI: { planMcpImport: mocks.planMcp, applyMcpImport: mocks.applyMcp, } })); +vi.mock('@/infrastructure/config/services/AgentCompanionPetService', () => ({ + listExternalAgentCompanionPets: mocks.getPets, importReviewedAgentCompanionPet: mocks.importPet, + AGENT_COMPANION_PETS_CHANGED: 'agent-companion-pets-changed', +})); +vi.mock('@/infrastructure/config/services/AIExperienceConfigService', () => ({ aiExperienceConfigService: { + saveSettings: mocks.savePetSettings, addChangeListener: mocks.petSettingsListener, +} })); vi.mock('@/infrastructure/api/service-api/AIApi', () => ({ aiApi: { listSubscriptionAccounts: mocks.getAccounts } })); vi.mock('@/infrastructure/api/service-api/InstructionSourcesAPI', () => ({ instructionSourcesAPI: { getCatalog: mocks.getInstructions } })); vi.mock('@/infrastructure/api/service-api/MCPAPI', () => ({ MCPAPI: { loadMCPJsonConfig: mocks.loadMcp, saveMCPJsonConfig: mocks.saveMcp } })); @@ -47,12 +55,12 @@ vi.mock('@openbitfun/ui', async (importOriginal) => { DialogClose: () => null, DialogDescription: Wrapper, DialogHeaderActions: Wrapper, DialogFooter: Wrapper, DialogHeader: Wrapper, DialogHeading: Wrapper, DialogTitle: Wrapper, DialogBody: Wrapper, Icon: () => , IconButton: ({ icon, ...props }: React.ButtonHTMLAttributes & { icon: React.ReactNode }) => , - Card: Wrapper, + Card: Wrapper, CardBody: Wrapper, CardHeader: ({ title, description }: { title?: React.ReactNode; description?: React.ReactNode }) =>
{title}{description}
, ScrollArea: Wrapper, LoadingState: Wrapper, OverflowText: Wrapper, StatusPill: Wrapper, }; }); -import ExternalAgentContent from './ExternalAgentContent'; +import ExternalAgentContent, { type ExternalAgentContentHandle } from './ExternalAgentContent'; import { clearEcosystemDiscoveryCache } from './ecosystemDiscoveryCache'; function fixture() { @@ -102,6 +110,10 @@ describe('external agent content and explicit import boundary', () => { mocks.remote = false; mocks.peer = false; mocks.workspacePath = '/project'; mocks.skillImportVersion = 0; data = fixture(); mocks.getAccounts.mockResolvedValue([]); + mocks.getPets.mockResolvedValue({ candidates: [], diagnostics: [] }); + mocks.getPetSettings.mockResolvedValue({ enable_agent_companion: false }); + mocks.savePetSettings.mockResolvedValue(undefined); + mocks.petSettingsListener.mockReturnValue(() => {}); mocks.getInstructions.mockResolvedValue({ schemaVersion: 1, entries: [], failedEcosystems: [] }); mocks.getSkills.mockImplementation(async () => [...data.skills]); mocks.getHooks.mockResolvedValue(data.hooks); @@ -123,9 +135,9 @@ describe('external agent content and explicit import boundary', () => { container = document.createElement('div'); document.body.append(container); root = createRoot(container); }); afterEach(async () => { await act(async () => root.unmount()); container.remove(); vi.unstubAllGlobals(); }); - async function render(product: EcosystemProductId = 'codex', catalogFailed = false) { + async function render(product: EcosystemProductId = 'codex', catalogFailed = false, onCounts?: (counts: Record) => void) { const runtime = buildEcosystemProductRuntimes(data.snapshot, []).find((entry) => entry.spec.id === product)!; - await act(async () => root.render()); + await act(async () => root.render()); } async function click(label: string, kind?: string) { if (kind && !container.querySelector(`[data-import-kind="${kind}"]`)) await expand(kind); @@ -236,6 +248,139 @@ describe('external agent content and explicit import boundary', () => { expect(container.querySelector('[data-content-group="account"] button')).toBeNull(); }); + function petFixture() { + const pet = { id: 'cat', displayName: 'Codex Cat', source: 'codex', packagePath: '/codex/pets/cat', spritesheetPath: '/codex/pets/cat/sprite.png', spritesheetMimeType: 'image/png', spriteVersionNumber: 2 }; + const candidate = { sourceKey: 'cat-key', fingerprint: 'reviewed-v1', pet, previewDataUrl: 'data:image/png;base64,AA==', imported: null as null | typeof pet, copyModified: false, sourceChanged: false }; + return { candidates: [candidate], diagnostics: [] as string[] }; + } + + it('opens pets in their own catalog dialog and refreshes them from the scene control', async () => { + mocks.getPets.mockResolvedValue(petFixture()); + const refreshControlRef = React.createRef(); + const runtime = buildEcosystemProductRuntimes(data.snapshot, []).find((entry) => entry.spec.id === 'codex')!; + await act(async () => root.render()); + await expand('pet'); + expect(container.textContent).toContain('Codex Cat'); + expect(container.querySelectorAll('[data-ecosystem-category="pet"]')).toHaveLength(1); + mocks.getPets.mockClear(); mocks.getAccounts.mockClear(); + await act(async () => refreshControlRef.current!.refresh()); + expect(mocks.getPets).toHaveBeenCalledTimes(1); + expect(mocks.getAccounts).toHaveBeenCalledTimes(1); + await click('close'); + await expand('skill'); + expect(container.querySelector('[data-ecosystem-category="pet"]')).toBeNull(); + expect(container.querySelector('[data-ecosystem-category="skill"]')).not.toBeNull(); + }); + + it('reuses pets on reopen and focus, and retains the list during an explicit refresh', async () => { + mocks.getPets.mockResolvedValue(petFixture()); + await render(); + expect(mocks.getPets).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain('Codex Cat'); + await expand('pet'); await click('close'); await expand('pet'); + await act(async () => window.dispatchEvent(new Event('focus'))); + expect(mocks.getPets).toHaveBeenCalledTimes(1); + let resolveRefresh!: (value: ReturnType) => void; + mocks.getPets.mockReturnValue(new Promise((resolve) => { resolveRefresh = resolve; })); + const refresh = container.querySelector('[data-ecosystem-category="pet"] button[aria-label="content.refresh"]')!; + await act(async () => refresh.click()); + expect(mocks.getPets).toHaveBeenCalledTimes(2); + expect(container.textContent).toContain('Codex Cat'); + expect(refresh.disabled).toBe(true); + await act(async () => resolveRefresh(petFixture())); + expect(refresh.disabled).toBe(false); + }); + + it('updates the active pet from settings without rescanning and closes the catalog to manage a copy', async () => { + const catalog = petFixture(); + catalog.candidates[0].imported = { ...catalog.candidates[0].pet, source: 'user', packagePath: '/native/cat' }; + mocks.getPets.mockResolvedValue(catalog); + await render(); await expand('pet'); + await act(async () => mocks.petSettingsListener.mock.lastCall![0]({ enable_agent_companion: true, agent_companion_pet: catalog.candidates[0].imported })); + expect(container.textContent).toContain('content.pets.states.using'); + expect(mocks.getPets).toHaveBeenCalledTimes(1); + await click('content.manageCopy'); + expect(container.querySelector('[data-ecosystem-category="pet"]')).toBeNull(); + expect(mocks.openDestination).toHaveBeenCalledWith({ pageId: 'application.pet' }); + }); + + it('reviews a pet before copying, then offers explicit use without selecting it during import', async () => { + const catalog = petFixture(); + mocks.getPets.mockResolvedValue(catalog); + mocks.importPet.mockImplementation(async () => { catalog.candidates[0].imported = { ...catalog.candidates[0].pet, source: 'user', packagePath: '/native/cat' }; return catalog.candidates[0].imported; }); + await render(); await expand('pet'); + expect(container.textContent).toContain('Codex Cat'); + expect(mocks.importPet).not.toHaveBeenCalled(); + await click('content.prepareImport'); + expect(container.querySelectorAll('[role="dialog"]')[1]?.textContent).toContain('content.pets.reviewDescription'); + await click('content.confirm'); + expect(mocks.importPet.mock.calls[0][0].fingerprint).toBe('reviewed-v1'); + expect(mocks.savePetSettings).not.toHaveBeenCalled(); + await click('content.pets.use'); + expect(mocks.savePetSettings).toHaveBeenCalledWith({ agent_companion_pet: expect.objectContaining({ packagePath: '/native/cat' }), enable_agent_companion: true }); + }); + + it('includes discovered pets in the ecosystem content total and clears removed sources', async () => { + const onCounts = vi.fn(); + await render('codex', false, onCounts); + const baseline = onCounts.mock.lastCall?.[0].codex ?? 0; + mocks.getPets.mockResolvedValue(petFixture()); + await act(async () => globalEventBus.emit('agent-companion-pets-changed', {})); + expect(onCounts.mock.lastCall?.[0].codex).toBe(baseline + 1); + mocks.getPets.mockResolvedValue({ candidates: [], diagnostics: [] }); + await act(async () => globalEventBus.emit('agent-companion-pets-changed', {})); + expect(onCounts.mock.lastCall?.[0].codex).toBe(baseline); + }); + + it('labels bundled pets and preserves their identity through the review dialog', async () => { + const catalog = petFixture(); + Object.assign(catalog.candidates[0], { builtinId: 'codex' }); + catalog.candidates[0].pet.packagePath = '/installed/app.asar'; + mocks.getPets.mockResolvedValue(catalog); + await render(); await expand('pet'); + expect(container.textContent).toContain('content.pets.builtinSource'); + await click('content.prepareImport'); + expect(container.querySelector('[role="dialog"]')?.textContent).toContain('content.pets.builtinSource'); + await click('content.confirm'); + expect(mocks.importPet).toHaveBeenCalledWith(expect.objectContaining({ builtinId: 'codex', fingerprint: 'reviewed-v1' })); + expect(mocks.savePetSettings).not.toHaveBeenCalled(); + }); + + it('refreshes pet import state after native deletion and retains edited copies', async () => { + const catalog = petFixture(); + catalog.candidates[0].imported = { ...catalog.candidates[0].pet, source: 'user', packagePath: '/native/cat' }; + catalog.candidates[0].copyModified = true; + mocks.getPets.mockResolvedValue(catalog); + await render(); await expand('pet'); + expect(container.textContent).toContain('content.pets.states.copyModified'); + expect(container.textContent).not.toContain('content.prepareImport'); + catalog.candidates[0].imported = null; catalog.candidates[0].copyModified = false; + await act(async () => globalEventBus.emit('agent-companion-pets-changed', {})); + expect(container.textContent).toContain('content.pets.states.ready'); + expect(container.textContent).toContain('content.prepareImport'); + }); + + it('shows a failed pet confirmation and never selects a disappeared native copy', async () => { + const catalog = petFixture(); mocks.getPets.mockResolvedValue(catalog); + mocks.importPet.mockRejectedValue(new Error('Source changed')); + await render(); await expand('pet'); await click('content.prepareImport'); await click('content.confirm'); + expect(container.textContent).toContain('content.pets.operationFailed'); + expect(mocks.savePetSettings).not.toHaveBeenCalled(); + catalog.candidates[0].imported = { ...catalog.candidates[0].pet, source: 'user', packagePath: '/native/cat' }; + await act(async () => globalEventBus.emit('agent-companion-pets-changed', {})); + mocks.getPets.mockResolvedValue(petFixture()); + await click('content.pets.use'); + expect(mocks.savePetSettings).not.toHaveBeenCalled(); + }); + + it('does not load local pets in an unsupported context or another ecosystem', async () => { + mocks.peer = true; await render(); await expand('pet'); + expect(mocks.getPets).not.toHaveBeenCalled(); + expect(container.textContent).toContain('content.pets.states.unsupported'); + mocks.peer = false; await render('claude-code'); + expect(mocks.getPets).not.toHaveBeenCalled(); + }); + it('keeps copy management on imported rows without category management links', async () => { data.plan.items[0].disposition = 'already_imported'; await render(); diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.tsx b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.tsx index 25a58d50d1..d6e13fa156 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.tsx +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ExternalAgentContent.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, us import { Alert, Button, Checkbox, DialogBody, DialogClose, DialogFooter, DialogHeader, DialogHeaderActions, DialogHeading, DialogTitle, Icon, IconButton, Input, LoadingState, OverflowText, ScrollArea, SearchField, Select, StatusPill, type IconSource } from '@openbitfun/ui'; import { EcosystemDialog as Dialog } from './EcosystemDialog'; import { EcosystemBatchLayout } from './EcosystemBatchLayout'; +import EcosystemPets from './EcosystemPets'; import EcosystemAccounts, { ecosystemAccountProvider } from './EcosystemAccounts'; import { presentEcosystemContent } from './ecosystemContentPresentation'; import { ecosystemDiscoveryCache, rememberEcosystemHooks, rememberEcosystemSkills } from './ecosystemDiscoveryCache'; @@ -92,8 +93,10 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe ? instructionSnapshot?.catalog ?? null : null; const [loading, setLoading] = useState(true); const [refreshing, setRefreshing] = useState(false); - const [accountRefreshVersion, setAccountRefreshVersion] = useState(0); + const [contentRefreshVersion, setContentRefreshVersion] = useState(0); const [accountExpanded, setAccountExpanded] = useState(false); + const [petDialogOpen, setPetDialogOpen] = useState(false); + const [petCount, setPetCount] = useState(0); const [loadFailures, setLoadFailures] = useState([]); const [plan, setPlan] = useState(null); const [planLoading, setPlanLoading] = useState(false); @@ -214,8 +217,11 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe for (const source of hooks?.catalog.sources ?? []) { counts[source.ecosystemId] = (counts[source.ecosystemId] ?? 0) + 1; } + if (localImportSupported && runtime.spec.id === 'codex') { + counts.codex = (counts.codex ?? 0) + petCount; + } onSupplementalCounts?.(counts); - }, [hooks, loading, onSupplementalCounts, skills]); + }, [hooks, loading, localImportSupported, onSupplementalCounts, petCount, runtime.spec.id, skills]); const hasMcp = items.some((item) => item.kind === 'mcp' && item.discovered); const refreshMcpPlan = useCallback(async (explicit = false) => { @@ -596,7 +602,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe const refreshContent = async () => { if (busy || refreshing) return; setRefreshing(true); - setAccountRefreshVersion((value) => value + 1); + setContentRefreshVersion((value) => value + 1); setNotice(null); void refreshMcpPlan(true); const results = await Promise.allSettled([loadSupplemental(true), onRefresh()]); @@ -630,8 +636,9 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe {t('import.columns.state')} {Array.from(new Set(items.map((item) => item.kind))).map((group) => { + if (group === 'pet' && runtime.spec.id === 'codex') return { setPetDialogOpen(open); setAccountExpanded(false); setSearch(''); setSelected(new Set()); }} />; const accountProvider = ecosystemAccountProvider(runtime.spec.id); - if (group === 'account' && accountProvider) return { setAccountExpanded((expanded) => !expanded); setSearch(''); setSelected(new Set()); }} />; + if (group === 'account' && accountProvider) return { setAccountExpanded((expanded) => !expanded); setPetDialogOpen(false); setSearch(''); setSelected(new Set()); }} />; const groupItems = items.filter((item) => item.kind === group); const representative = groupItems[0]; const count = groupItems.filter((item) => item.discovered).length; @@ -663,7 +670,7 @@ export default function ExternalAgentContent({ scopeKey, refreshControlRef, onRe icon={} aria-label={t('content.viewCategory', { type: t(`capabilities.${group}`) })} aria-haspopup="dialog" aria-controls={categoryDialogId} - onClick={() => { setAccountExpanded(false); setKind(group); setSearch(''); setSelected(new Set()); setNotice(null); }} /> : null} + onClick={() => { setAccountExpanded(false); setPetDialogOpen(false); setKind(group); setSearch(''); setSelected(new Set()); setNotice(null); }} /> : null}
; diff --git a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemCompatibilityModel.ts b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemCompatibilityModel.ts index ab5591bbca..9b6f69604b 100644 --- a/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemCompatibilityModel.ts +++ b/src/web-ui/src/app/scenes/ecosystem-compatibility/ecosystemCompatibilityModel.ts @@ -113,7 +113,7 @@ export const ECOSYSTEM_IMPORT_ITEM_KINDS: readonly EcosystemImportItemKind[] = [ */ const PRODUCT_DISCOVERY_KINDS = { 'claude-code': ['command', 'subagent', 'skill', 'mcp', 'hook'], - codex: ['subagent', 'skill', 'mcp', 'hook'], + codex: ['subagent', 'skill', 'mcp', 'hook', 'pet'], pi: ['skill', 'hook'], dsh: ['skill', 'hook', 'mcp'], opencode: ['command', 'tool', 'subagent', 'skill', 'mcp', 'hook'], @@ -127,7 +127,7 @@ const PRODUCT_NOT_APPLICABLE_KINDS = { opencode: ['pet'], } as const satisfies Record; -const OWNER_DETECTED_KINDS = new Set(['skill', 'hook', 'instruction']); +const OWNER_DETECTED_KINDS = new Set(['skill', 'hook', 'instruction', 'pet']); export function ecosystemDiscoverySupport( productId: EcosystemProductId, diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts index e64103bf38..f357b334f7 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.test.ts @@ -68,3 +68,69 @@ describe('AgentCompanionPetService built-in presets', () => { expect(invoke).not.toHaveBeenCalled(); }); }); + + +describe('reviewed external pet protocol', () => { + beforeEach(() => invoke.mockReset()); + + it('rejects hosts without reviewed import capability and unsafe previews', async () => { + const { listExternalAgentCompanionPets } = await import('./AgentCompanionPetService'); + invoke.mockResolvedValueOnce({ pets: [] }); + await expect(listExternalAgentCompanionPets()).rejects.toThrow('unavailable'); + invoke.mockResolvedValueOnce({ importOperationsVersion: 1, external: { + candidates: [{ sourceKey: 'cat', fingerprint: 'hash', pet: { packagePath: '/source/cat' }, previewDataUrl: 'file:///private/image' }], diagnostics: [], + } }); + await expect(listExternalAgentCompanionPets()).rejects.toThrow('unavailable'); + expect(invoke).toHaveBeenCalledWith('list_agent_companion_pets', { request: { includeExternal: true, builtinImportVersion: 1 } }); + }); + + it('sends the reviewed fingerprint and emits changes only after a successful copy', async () => { + const { importReviewedAgentCompanionPet, AGENT_COMPANION_PETS_CHANGED } = await import('./AgentCompanionPetService'); + const { globalEventBus } = await import('@/infrastructure/event-bus'); + const changed = vi.fn(); + const off = globalEventBus.on(AGENT_COMPANION_PETS_CHANGED, changed); + const candidate = { + sourceKey: 'cat', fingerprint: 'reviewed-hash', previewDataUrl: 'data:image/png;base64,AA==', + pet: { id: 'cat', displayName: 'Cat', source: 'codex' as const, packagePath: '/source/cat', spritesheetPath: '/source/cat/sprite.png', spritesheetMimeType: 'image/png' }, + imported: null, copyModified: false, sourceChanged: false, + }; + try { + invoke.mockRejectedValueOnce(new Error('Source changed')); + await expect(importReviewedAgentCompanionPet(candidate)).rejects.toThrow('Source changed'); + expect(changed).not.toHaveBeenCalled(); + invoke.mockResolvedValueOnce({ ...candidate.pet, source: 'user', packagePath: '/native/cat' }); + await expect(importReviewedAgentCompanionPet(candidate)).resolves.toMatchObject({ packagePath: '/native/cat' }); + expect(invoke).toHaveBeenLastCalledWith('import_agent_companion_pet_package', { + request: { path: '/source/cat', expectedFingerprint: 'reviewed-hash' }, + }); + expect(changed).toHaveBeenCalledTimes(1); + } finally { off(); } + }); +}); + + +describe('bundled pet compatibility', () => { + it('requires explicit bundled-import support while keeping custom-only older hosts usable', async () => { + const { listExternalAgentCompanionPets } = await import('./AgentCompanionPetService'); + const candidate = { builtinId: 'codex', sourceKey: 'builtin:codex', fingerprint: 'hash', pet: { packagePath: '/app/app.asar' }, previewDataUrl: 'data:image/png;base64,AA==' }; + invoke.mockResolvedValueOnce({ importOperationsVersion: 1, external: { candidates: [candidate], diagnostics: [] } }); + await expect(listExternalAgentCompanionPets()).rejects.toThrow('unavailable'); + invoke.mockResolvedValueOnce({ importOperationsVersion: 1, external: { candidates: [], diagnostics: [] } }); + await expect(listExternalAgentCompanionPets()).resolves.toEqual({ candidates: [], diagnostics: [] }); + invoke.mockResolvedValueOnce({ importOperationsVersion: 1, builtinImportVersion: 1, external: { candidates: [candidate], diagnostics: [] } }); + await expect(listExternalAgentCompanionPets()).resolves.toMatchObject({ candidates: [{ builtinId: 'codex' }] }); + }); + + it('sends the stable bundled identity with the reviewed fingerprint', async () => { + const { importReviewedAgentCompanionPet, DEFAULT_AGENT_COMPANION_PET } = await import('./AgentCompanionPetService'); + invoke.mockResolvedValueOnce(DEFAULT_AGENT_COMPANION_PET); + await importReviewedAgentCompanionPet({ + builtinId: 'codex', sourceKey: 'builtin:codex', fingerprint: 'reviewed', + pet: { ...DEFAULT_AGENT_COMPANION_PET, source: 'codex', packagePath: '/installed/app.asar' }, + previewDataUrl: 'data:image/png;base64,AA==', imported: null, copyModified: false, sourceChanged: false, + }); + expect(invoke).toHaveBeenLastCalledWith('import_agent_companion_pet_package', { + request: { path: '/installed/app.asar', builtinId: 'codex', expectedFingerprint: 'reviewed' }, + }); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts index cf52a52109..d55045bd4c 100644 --- a/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts +++ b/src/web-ui/src/infrastructure/config/services/AgentCompanionPetService.ts @@ -1,3 +1,4 @@ +import { globalEventBus } from '@/infrastructure/event-bus'; import { api } from '@/infrastructure/api/service-api/ApiClient'; import { readFile } from '@tauri-apps/plugin-fs'; import type { AgentCompanionPetSelection } from './AIExperienceConfigService'; @@ -10,6 +11,8 @@ const log = createLogger('AgentCompanionPetService'); const BUILTIN_PET_BASE = '/agent-companion-pets'; const BUILTIN_PET_DISPLAY_NAMES = builtinPetMetadata.displayNames; +export const AGENT_COMPANION_PETS_CHANGED = 'agent-companion-pets-changed'; + export const DEFAULT_AGENT_COMPANION_PET: AgentCompanionPetSelection = { id: 'blue-golden', displayName: BUILTIN_PET_DISPLAY_NAMES.blueGolden, @@ -216,6 +219,7 @@ export async function importAgentCompanionPetPackage(path: string): Promise('import_agent_companion_pet_package', { request: { path }, }); + globalEventBus.emit(AGENT_COMPANION_PETS_CHANGED, {}); return withPreviewSrc(pet); } @@ -223,6 +227,7 @@ export async function deleteAgentCompanionPetPackage(packagePath: string): Promi await api.invoke('delete_agent_companion_pet_package', { request: { packagePath }, }); + globalEventBus.emit(AGENT_COMPANION_PETS_CHANGED, {}); } /** @@ -252,3 +257,49 @@ export async function resolveAgentCompanionPet(pet: AgentCompanionPetSelection) const src = await resolveAgentCompanionPetSrc(resolved); return { src, layout }; } + + +export interface ExternalPetCandidate { + sourceKey: string; + fingerprint: string; + pet: Omit & { source: 'codex' }; + previewDataUrl: string; + imported: AgentCompanionPetSelection | null; + copyModified: boolean; + sourceChanged: boolean; + builtinId?: string; +} + +export interface ExternalPetCatalog { + candidates: ExternalPetCandidate[]; + diagnostics: string[]; +} + +/** An explicit version proves the host checks the reviewed package before copying. */ +export async function listExternalAgentCompanionPets(): Promise { + const response = await api.invoke<{ + importOperationsVersion?: number; + builtinImportVersion?: number; + external?: ExternalPetCatalog; + }>('list_agent_companion_pets', { request: { includeExternal: true, builtinImportVersion: 1 } }); + if (response.importOperationsVersion !== 1 || !response.external + || !Array.isArray(response.external.candidates) || !Array.isArray(response.external.diagnostics) + || response.external.candidates.some((entry) => (entry.builtinId != null + && (typeof entry.builtinId !== 'string' || !entry.builtinId || response.builtinImportVersion !== 1)) + || typeof entry.sourceKey !== 'string' + || typeof entry.fingerprint !== 'string' || typeof entry.pet?.packagePath !== 'string' + || typeof entry.previewDataUrl !== 'string' || !entry.previewDataUrl.startsWith('data:image/png;base64,'))) { + throw new Error('Pet discovery or reviewed import is unavailable on this host'); + } + return response.external; +} + +export async function importReviewedAgentCompanionPet(candidate: ExternalPetCandidate): Promise { + const pet = await api.invoke('import_agent_companion_pet_package', { + request: { path: candidate.pet.packagePath, expectedFingerprint: candidate.fingerprint, + ...(candidate.builtinId ? { builtinId: candidate.builtinId } : {}), + }, + }); + globalEventBus.emit(AGENT_COMPANION_PETS_CHANGED, {}); + return pet; +} diff --git a/src/web-ui/src/locales/en-US/scenes/ecosystem-compatibility.json b/src/web-ui/src/locales/en-US/scenes/ecosystem-compatibility.json index 8bd6ddbe09..a49be45dee 100644 --- a/src/web-ui/src/locales/en-US/scenes/ecosystem-compatibility.json +++ b/src/web-ui/src/locales/en-US/scenes/ecosystem-compatibility.json @@ -343,6 +343,28 @@ "notConnected": "Not connected" }, "refresh": "Refresh account status" + }, + "pets": { + "builtinSource": "Codex built-in", + "customSource": "Codex custom", + "description": "Browse built-in and custom Codex pets, then import them to use in OpenBitFun.", + "partial": "Some pets could not be read. Check the source diagnostics below.", + "reviewTitle": "Import pet", + "reviewDescription": "Copy the reviewed pet package into OpenBitFun. You can select it after import; your current pet stays selected.", + "operationFailed": "The operation did not complete. The source or copy may have changed. Refresh and review again.", + "use": "Use this pet", + "states": { + "unsupported": "Unsupported", + "failed": "Load failed", + "partial": "Partly available", + "available": "Discovery supported", + "empty": "No pets found", + "copyModified": "Copy modified", + "sourceChanged": "Source changed", + "using": "In use", + "imported": "Imported", + "ready": "Ready to import" + } } }, "sourceSettings": { diff --git a/src/web-ui/src/locales/zh-CN/scenes/ecosystem-compatibility.json b/src/web-ui/src/locales/zh-CN/scenes/ecosystem-compatibility.json index a9626364e0..cae7971213 100644 --- a/src/web-ui/src/locales/zh-CN/scenes/ecosystem-compatibility.json +++ b/src/web-ui/src/locales/zh-CN/scenes/ecosystem-compatibility.json @@ -343,6 +343,28 @@ "notConnected": "未接入" }, "refresh": "刷新账号状态" + }, + "pets": { + "builtinSource": "Codex 内置", + "customSource": "Codex 自定义", + "description": "查看 Codex 内置与自定义宠物,导入后可在 OpenBitFun 中使用。", + "partial": "部分宠物无法读取,请检查下方来源诊断。", + "reviewTitle": "导入宠物", + "reviewDescription": "将已预览的宠物包复制到 OpenBitFun。导入后可单独选择使用,当前宠物保持不变。", + "operationFailed": "操作未完成,来源或副本可能已变化。请刷新后重新确认。", + "use": "使用此宠物", + "states": { + "unsupported": "暂不支持", + "failed": "读取失败", + "partial": "部分可用", + "available": "支持发现", + "empty": "未发现宠物", + "copyModified": "副本已修改", + "sourceChanged": "来源已变化", + "using": "正在使用", + "imported": "已导入", + "ready": "可导入" + } } }, "sourceSettings": { diff --git a/src/web-ui/src/locales/zh-TW/scenes/ecosystem-compatibility.json b/src/web-ui/src/locales/zh-TW/scenes/ecosystem-compatibility.json index c622eac110..70e1dcd8a0 100644 --- a/src/web-ui/src/locales/zh-TW/scenes/ecosystem-compatibility.json +++ b/src/web-ui/src/locales/zh-TW/scenes/ecosystem-compatibility.json @@ -343,6 +343,28 @@ "notConnected": "未接入" }, "refresh": "重新整理帳號狀態" + }, + "pets": { + "builtinSource": "Codex 內建", + "customSource": "Codex 自訂", + "description": "查看 Codex 內建與自訂寵物,匯入後可在 OpenBitFun 中使用。", + "partial": "部分寵物無法讀取,請檢查下方來源診斷。", + "reviewTitle": "匯入寵物", + "reviewDescription": "將已預覽的寵物套件複製到 OpenBitFun。匯入後可單獨選擇使用,目前寵物保持不變。", + "operationFailed": "操作未完成,來源或副本可能已變更。請重新整理後再次確認。", + "use": "使用此寵物", + "states": { + "unsupported": "暫不支援", + "failed": "讀取失敗", + "partial": "部分可用", + "available": "支援探索", + "empty": "未發現寵物", + "copyModified": "副本已修改", + "sourceChanged": "來源已變更", + "using": "正在使用", + "imported": "已匯入", + "ready": "可匯入" + } } }, "sourceSettings": {