Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -329,7 +329,7 @@
"visibility": "documented",
"rustPath": "add_skill",
"sourceFile": "src/apps/desktop/src/api/skill_api.rs",
"signature": "fn add_skill( _state: State<'_, AppState>, source_path: String, level: String, workspace_path: Option<String>, source_key: Option<String>, target_name: Option<String>, ) -> Result<String, String>",
"signature": "fn add_skill( _state: State<'_, AppState>, source_path: String, level: String, workspace_path: Option<String>, source_key: Option<String>, target_name: Option<String>, expected_source_fingerprint: Option<String>, ) -> Result<String, String>",
"remoteWorkspacePolicy": "LegacyUnaudited"
},
{
Expand Down Expand Up @@ -10200,7 +10200,7 @@
"visibility": "documented",
"rustPath": "validate_skill_path",
"sourceFile": "src/apps/desktop/src/api/skill_api.rs",
"signature": "fn validate_skill_path(path: String) -> Result<SkillValidationResult, String>",
"signature": "fn validate_skill_path( path: String, source_key: Option<String>, workspace_path: Option<String>, ) -> Result<SkillValidationResult, String>",
"remoteWorkspacePolicy": "LegacyUnaudited"
},
{
Expand Down
14 changes: 14 additions & 0 deletions scripts/core-boundaries/rules/source/public-api-rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,20 @@ export const opencodeAdapterPublicApiEntries = [
'OpenCodeConfiguredSkillRoot',
'openbitfun-core external source composition root and OpenCode configured Skill fixtures',
),
...['OpenCodeSkillRootDiagnostic', 'OpenCodeSkillRootReport'].map((symbol) => ({
symbol,
owner: 'opencode-adapter configured Skill root source owner',
consumer: 'openbitfun-core external_sources::opencode_configured_skill_roots_with_provider',
verification:
'opencode_static_source_contracts::opencode_skill_roots diagnostics fixtures and core-boundary public API checks',
p0: 'runtime-free configured local Skill root discovery with bounded diagnostics',
contractSlice: contractSlices.opencodeAdapterBoundary,
wireImpact: false,
rationale:
'Product Assembly projects valid roots and redacted discovery failures into its neutral Skill scan report without exposing OpenCode configuration syntax',
exit:
'remove only with the configured Skill root provider or an equivalent reviewed diagnostic projection',
})),
opencodeAdapterEntry(
'OpenCodeSkillRootProvider',
'openbitfun-core external source composition root and OpenCode configured Skill fixtures',
Expand Down
2 changes: 1 addition & 1 deletion scripts/core-boundaries/self-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1638,7 +1638,7 @@ export function runManifestParserSelfTest({
).map((entry) => entry.symbol);
if (
opencodeAdapterPublicApiSymbols.join(',') !==
'load_opencode_package_adapter,load_opencode_config_snapshot,OpenCodeConfigSnapshot,OpenCodeConfigSnapshotError,project_plugin_config,project_plugin_tool_ref,OpenCodePluginConfigProjectionError,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeConfiguredSkillRoot,OpenCodeSkillRootProvider,OpenCodeSkillRootProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions,OpenCodeHookProvider,OpenCodeHookProviderOptions,OpenCodeWorkspaceReferenceProvider,OpenCodeWorkspaceReferenceProviderOptions,load_opencode_user_instructions,OpenCodeInstructionSourceOptions'
'load_opencode_package_adapter,load_opencode_config_snapshot,OpenCodeConfigSnapshot,OpenCodeConfigSnapshotError,project_plugin_config,project_plugin_tool_ref,OpenCodePluginConfigProjectionError,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeConfiguredSkillRoot,OpenCodeSkillRootDiagnostic,OpenCodeSkillRootReport,OpenCodeSkillRootProvider,OpenCodeSkillRootProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions,OpenCodeMcpProvider,OpenCodeMcpProviderOptions,OpenCodeHookProvider,OpenCodeHookProviderOptions,OpenCodeWorkspaceReferenceProvider,OpenCodeWorkspaceReferenceProviderOptions,load_opencode_user_instructions,OpenCodeInstructionSourceOptions'
) {
throw new Error(
'OpenCode adapter public API budget must stay limited to the reviewed package factory, managed Plugin Host Config projection, and capability-specific command, configured Skill root, tool, subagent, MCP, static Hook, workspace Reference, and user Instruction providers',
Expand Down
128 changes: 102 additions & 26 deletions src/apps/desktop/src/api/skill_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ use openbitfun_core::agentic::tools::implementations::skills::mode_overrides::{
save_project_mode_skills_document_local, set_disabled_mode_skills_in_document,
set_global_user_skill_disabled, set_mode_skill_disabled_in_document, set_user_mode_skill_state,
};
use openbitfun_core::agentic::tools::implementations::skills::registry::imports::{
self as skill_imports, SkillImportPreview,
};
use openbitfun_core::agentic::tools::implementations::skills::{
resolver::resolve_skill_default_enabled_for_mode, ModeSkillInfo, SkillData, SkillInfo,
SkillLocation, SkillRegistry, SkillScanReport,
Expand Down Expand Up @@ -86,6 +89,12 @@ fn ensure_skill_can_be_deleted(skill: &SkillInfo) -> Result<(), String> {

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SkillValidationResult {
#[serde(
default,
rename = "importPreview",
skip_serializing_if = "Option::is_none"
)]
pub import_preview: Option<SkillImportPreview>,
pub valid: bool,
pub name: Option<String>,
pub description: Option<String>,
Expand Down Expand Up @@ -574,7 +583,7 @@ pub async fn get_skill_configs(
};
object.insert(
"importOperationsVersion".into(),
serde_json::json!(if supported { 2 } else { 0 }),
serde_json::json!(if supported { 3 } else { 0 }),
);
}
Ok(response)
Expand Down Expand Up @@ -885,14 +894,58 @@ pub async fn reset_mode_skill_selection(
))
}

async fn resolve_external_skill_import_source(
source_path: &str,
source_key: &str,
workspace: Option<&Path>,
) -> Result<SkillInfo, String> {
if let Some(root) = workspace {
if is_remote_path(&root.to_string_lossy()).await {
return Err("External Skill import into remote workspaces is not supported".into());
}
}
let source = SkillRegistry::global()
.find_skill_by_key_for_workspace(source_key, workspace)
.await
.ok_or("skill_import_stale: External Skill source changed; refresh before importing")?;
if tokio::fs::canonicalize(&source.path)
.await
.map_err(|error| error.to_string())?
!= tokio::fs::canonicalize(source_path)
.await
.map_err(|error| error.to_string())?
{
return Err("External Skill path does not match the selected source".into());
}
Ok(source)
}

#[tauri::command]
pub async fn validate_skill_path(path: String) -> Result<SkillValidationResult, String> {
pub async fn validate_skill_path(
path: String,
source_key: Option<String>,
workspace_path: Option<String>,
) -> Result<SkillValidationResult, String> {
use std::path::Path;
if let Some(source_key) = source_key {
let workspace = workspace_root_from_input(workspace_path.as_deref());
let source =
resolve_external_skill_import_source(&path, &source_key, workspace.as_deref()).await?;
let preview = skill_imports::preview_import(source).await?;
return Ok(SkillValidationResult {
valid: true,
name: Some(preview.name.clone()),
description: Some(preview.description.clone()),
error: None,
import_preview: Some(preview),
});
}

let skill_path = Path::new(&path);

if !skill_path.exists() {
return Ok(SkillValidationResult {
import_preview: None,
valid: false,
name: None,
description: None,
Expand All @@ -902,6 +955,7 @@ pub async fn validate_skill_path(path: String) -> Result<SkillValidationResult,

if !skill_path.is_dir() {
return Ok(SkillValidationResult {
import_preview: None,
valid: false,
name: None,
description: None,
Expand All @@ -912,6 +966,7 @@ pub async fn validate_skill_path(path: String) -> Result<SkillValidationResult,
let skill_md_path = skill_path.join("SKILL.md");
if !skill_md_path.exists() {
return Ok(SkillValidationResult {
import_preview: None,
valid: false,
name: None,
description: None,
Expand All @@ -923,12 +978,14 @@ pub async fn validate_skill_path(path: String) -> Result<SkillValidationResult,
Ok(content) => {
match SkillData::from_markdown(path.clone(), &content, SkillLocation::User, false) {
Ok(data) => Ok(SkillValidationResult {
import_preview: None,
valid: true,
name: Some(data.name),
description: Some(data.description),
error: None,
}),
Err(e) => Ok(SkillValidationResult {
import_preview: None,
valid: false,
name: None,
description: None,
Expand All @@ -937,6 +994,7 @@ pub async fn validate_skill_path(path: String) -> Result<SkillValidationResult,
}
}
Err(e) => Ok(SkillValidationResult {
import_preview: None,
valid: false,
name: None,
description: None,
Expand All @@ -953,30 +1011,16 @@ pub async fn add_skill(
workspace_path: Option<String>,
source_key: Option<String>,
target_name: Option<String>,
expected_source_fingerprint: Option<String>,
) -> Result<String, String> {
if let Some(source_key) = source_key {
if !matches!(level.as_str(), "user" | "project") {
return Err("Invalid Skill target scope".into());
}
let workspace = workspace_root_from_input(workspace_path.as_deref());
if let Some(root) = &workspace {
if is_remote_path(&root.to_string_lossy()).await {
return Err("External Skill import into remote workspaces is not supported".into());
}
}
let source = SkillRegistry::global()
.find_skill_by_key_for_workspace(&source_key, workspace.as_deref())
.await
.ok_or("External Skill source changed; refresh before importing")?;
if tokio::fs::canonicalize(&source.path)
.await
.map_err(|error| error.to_string())?
!= tokio::fs::canonicalize(&source_path)
.await
.map_err(|error| error.to_string())?
{
return Err("External Skill path does not match the selected source".into());
}
let source =
resolve_external_skill_import_source(&source_path, &source_key, workspace.as_deref())
.await?;
let paths = get_path_manager_arc();
let target = if level == "project" {
paths
Expand All @@ -985,19 +1029,22 @@ pub async fn add_skill(
} else {
paths.user_skills_dir()
};
openbitfun_core::agentic::tools::implementations::skills::registry::imports::import_copy_as(
source, target, target_name,
skill_imports::import_copy_as_reviewed(
source,
target,
target_name,
expected_source_fingerprint,
)
.await?;
SkillRegistry::global()
.refresh_for_workspace(workspace.as_deref())
.await;
return Ok("External Skill imported successfully".into());
}
if target_name.is_some() {
return Err("Renaming an imported Skill requires its source identity".into());
if target_name.is_some() || expected_source_fingerprint.is_some() {
return Err("Reviewed or renamed Skill imports require their source identity".into());
}
let validation = validate_skill_path(source_path.clone()).await?;
let validation = validate_skill_path(source_path.clone(), None, None).await?;
if !validation.valid {
return Err(validation.error.unwrap_or("Invalid skill path".to_string()));
}
Expand Down Expand Up @@ -1152,7 +1199,7 @@ pub async fn delete_skill(
let skill_path = std::path::PathBuf::from(&skill_info.path);

if let Some(expected) = expected_import_id {
openbitfun_core::agentic::tools::implementations::skills::registry::imports::remove_imported_copy(&skill_path, &expected).await?;
skill_imports::remove_imported_copy(&skill_path, &expected).await?;
} else if skill_path.exists() {
if let Err(e) = tokio::fs::remove_dir_all(&skill_path).await {
return Err(format!("Failed to delete skill folder: {}", e));
Expand All @@ -1177,6 +1224,35 @@ mod tests {
use std::future;
use tokio::time::Duration;

#[test]
fn skill_validation_reads_and_round_trips_legacy_payloads() {
let old = serde_json::json!({ "valid": true, "name": "demo", "description": "existing", "error": null });
let parsed: super::SkillValidationResult = serde_json::from_value(old.clone()).unwrap();
assert!(parsed.import_preview.is_none());
assert_eq!(serde_json::to_value(parsed).unwrap(), old);
let new = serde_json::json!({ "valid": true, "name": "demo", "description": "existing", "error": null,
"importPreview": { "fingerprint": "reviewed", "fileCount": 2, "name": "demo", "description": "existing" } });
let parsed: super::SkillValidationResult = serde_json::from_value(new.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), new);
}

#[tokio::test]
async fn legacy_skill_path_validation_remains_available_without_source_identity() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("SKILL.md"),
"---\nname: demo\ndescription: Existing\n---\nBody",
)
.unwrap();
let result =
super::validate_skill_path(temp.path().to_string_lossy().into_owned(), None, None)
.await
.unwrap();
assert!(result.valid);
assert!(result.import_preview.is_none());
assert_eq!(result.name.as_deref(), Some("demo"));
}

#[test]
fn skill_scan_response_preserves_legacy_arrays_and_opt_in_diagnostics() {
let report = super::SkillScanReport {
Expand Down
13 changes: 10 additions & 3 deletions src/crates/adapters/claude-code-adapter/src/agent_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ pub struct ClaudeCodeSubagentProviderOptions {
impl ClaudeCodeSubagentProviderOptions {
pub fn from_environment() -> Self {
Self {
user_claude_dir: dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".claude"),
user_claude_dir: crate::ClaudeCodeInstructionSourceOptions::from_environment()
.config_dir
.unwrap_or_default(),
project_root_override: None,
project_config_enabled: true,
}
Expand Down Expand Up @@ -140,6 +140,13 @@ impl ExternalSubagentSourceProvider for ClaudeCodeSubagentProvider {
&self,
input: &ExternalSubagentDiscoveryInput,
) -> Result<ExternalSubagentProviderSnapshot, ExternalSourceProviderError> {
if !self.options.user_claude_dir.is_absolute() {
return Err(ExternalSourceProviderError::new(
"claude.config_root_invalid",
"Claude Code configuration directory must be absolute",
false,
));
}
if input
.context
.workspace_root
Expand Down
17 changes: 13 additions & 4 deletions src/crates/adapters/claude-code-adapter/src/command_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,9 @@ pub struct ClaudeCodeCommandProviderOptions {
impl ClaudeCodeCommandProviderOptions {
pub fn from_environment() -> Self {
Self {
user_claude_dir: dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".claude"),
user_claude_dir: crate::ClaudeCodeInstructionSourceOptions::from_environment()
.config_dir
.unwrap_or_default(),
project_root_override: None,
project_config_enabled: true,
}
Expand Down Expand Up @@ -113,6 +113,13 @@ impl PromptCommandSourceProvider for ClaudeCodeCommandProvider {
&self,
context: &ExternalSourceContext,
) -> Result<PromptCommandProviderSnapshot, ExternalSourceProviderError> {
if !self.options.user_claude_dir.is_absolute() {
return Err(ExternalSourceProviderError::new(
"claude.config_root_invalid",
"Claude Code configuration directory must be absolute",
false,
));
}
if context
.workspace_root
.as_ref()
Expand Down Expand Up @@ -345,7 +352,9 @@ impl PromptCommandSourceProvider for ClaudeCodeCommandProvider {

fn watch_roots(&self, context: &ExternalSourceContext) -> Vec<ExternalWatchRoot> {
let mut roots = BTreeMap::new();
roots.insert(self.options.user_claude_dir.clone(), true);
if self.options.user_claude_dir.is_absolute() {
roots.insert(self.options.user_claude_dir.clone(), true);
}
if self.options.project_config_enabled {
if let Some(workspace) = &context.workspace_root {
let project_root = self.project_root(workspace);
Expand Down
20 changes: 17 additions & 3 deletions src/crates/adapters/claude-code-adapter/src/hook_source.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@ pub struct ClaudeCodeHookProviderOptions {

impl ClaudeCodeHookProviderOptions {
pub fn from_environment() -> Self {
let user_settings_file = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".claude/settings.json");
let user_settings_file = crate::ClaudeCodeInstructionSourceOptions::from_environment()
.config_dir
.unwrap_or_default()
.join("settings.json");
Self {
user_settings_file,
project_root_override: None,
Expand Down Expand Up @@ -158,6 +159,13 @@ impl ExternalHookSourceProvider for ClaudeCodeHookProvider {
&self,
context: &ExternalSourceContext,
) -> Result<ExternalHookProviderSnapshot, ExternalSourceProviderError> {
if !self.options.user_settings_file.is_absolute() {
return Err(ExternalSourceProviderError::new(
"claude.config_root_invalid",
"Claude Code configuration directory must be absolute",
false,
));
}
if context
.workspace_root
.as_ref()
Expand Down Expand Up @@ -253,6 +261,12 @@ impl ExternalHookSourceProvider for ClaudeCodeHookProvider {
requested_source: &SourceKey,
expected_catalog_content_version: &str,
) -> Result<PreparedExternalHookImport, ExternalSourceProviderError> {
if !self.options.user_settings_file.is_absolute() {
return Err(import_error(
"claude.config_root_invalid",
"Claude Code configuration directory must be absolute",
));
}
if requested_source.provider_id.as_str() != PROVIDER_ID {
return Err(import_error(
"claude.hook.import_provider_mismatch",
Expand Down
Loading
Loading