diff --git a/Cargo.lock b/Cargo.lock index 65209280b..07caa0fb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -421,6 +421,7 @@ dependencies = [ "aionui-session-message", "aionui-shell", "aionui-sidebar", + "aionui-skill-runtime", "aionui-system", "aionui-team", "aionui-team-prompts", @@ -958,6 +959,28 @@ dependencies = [ "url", ] +[[package]] +name = "aionui-skill-runtime" +version = "0.1.72" +dependencies = [ + "aionui-ai-agent", + "aionui-api-types", + "aionui-common", + "aionui-db", + "aionui-extension", + "async-trait", + "axum", + "http-body-util", + "serde", + "serde_json", + "sqlx", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tower", + "tracing", +] + [[package]] name = "aionui-system" version = "0.1.72" diff --git a/Cargo.toml b/Cargo.toml index ab8f1a61c..18924da28 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,7 @@ members = [ "crates/aionui-process", "crates/aionui-session", "crates/aionui-session-message", + "crates/aionui-skill-runtime", "crates/aionui-auth", "crates/aionui-system", "crates/aionui-file", @@ -45,6 +46,7 @@ aionui-runtime = { path = "crates/aionui-runtime" } aionui-process = { path = "crates/aionui-process" } aionui-session = { path = "crates/aionui-session" } aionui-session-message = { path = "crates/aionui-session-message" } +aionui-skill-runtime = { path = "crates/aionui-skill-runtime" } aionui-auth = { path = "crates/aionui-auth" } aionui-system = { path = "crates/aionui-system" } aionui-file = { path = "crates/aionui-file" } diff --git a/crates/aionui-ai-agent/src/capability/first_message_injector.rs b/crates/aionui-ai-agent/src/capability/first_message_injector.rs index 180d354b8..ae337d0e8 100644 --- a/crates/aionui-ai-agent/src/capability/first_message_injector.rs +++ b/crates/aionui-ai-agent/src/capability/first_message_injector.rs @@ -7,6 +7,8 @@ use std::sync::Arc; +use aionui_api_types::SkillDeliveryMode; + use crate::capability::skill_manager::{AcpSkillManager, prepare_first_message_with_skills_index}; /// Configuration for the first-message injector. @@ -17,25 +19,29 @@ pub struct InjectionConfig<'a> { pub preset_context: Option<&'a str>, /// Resolved skill names (snapshot from `conversation.extra.skills`). pub skills: &'a [String], - /// True iff the agent's native CLI reads skills from the workspace - /// without needing prompt injection. Derived by callers from - /// `AcpBackend::native_skills_dirs().is_some()` for ACP. - pub native_skill_support: bool, + /// How this vendor receives skills. + /// + /// This replaced a `native_skill_support: bool` derived from + /// `native_skills_dirs.is_some()`. That signal conflated two different + /// things — "declares a workspace skills directory" and "can discover + /// skills natively" — so a vendor with a declared directory but no working + /// discovery got LIGHT injection and therefore no skills at all. + pub delivery_mode: SkillDeliveryMode, } -/// Produce the content string to send as the first ACP prompt. +/// Produce the content string to send as the first prompt. /// -/// - If `native_skill_support`: **light mode** — only `preset_context` -/// prepended as an `[Assistant Rules]` block (if present). The native CLI -/// handles skill discovery via workspace links. -/// - Else: **heavy mode** — `preset_context` + resolved skills index -/// injected via `prepare_first_message_with_skills_index`. +/// Two states, from three modes: +/// * `Argv` / `Protocol` — LIGHT: only `preset_context`. The CLI owns skill +/// discovery, and injecting an index would duplicate the name+description +/// that a plugin-registering CLI already adds always-on. +/// * `Injected` — the skills index plus the dual-channel instructions. pub async fn inject_first_message_prefix( content: &str, manager: &Arc, config: InjectionConfig<'_>, ) -> String { - if config.native_skill_support { + if is_light_mode(&config.delivery_mode) { return match config.preset_context { Some(ctx) if !ctx.is_empty() => { format!("[Assistant Rules]\n{ctx}\n[/Assistant Rules]\n\n{content}") @@ -52,6 +58,31 @@ pub async fn inject_first_message_prefix( prepare_first_message_with_skills_index(content, &skills, config.preset_context) } +/// The same block [`inject_first_message_prefix`] would prepend, WITHOUT the +/// user's content appended — `None` when there is nothing to inject. +/// +/// For backends that carry the prefix separately from the turn's message rather +/// than concatenating it once: agy re-invokes its CLI per turn, so its rules +/// belong on the first invocation only. Sharing this function with the +/// concatenating path is what keeps the two from drifting into different wording. +pub async fn compose_injected_prefix(manager: &Arc, config: InjectionConfig<'_>) -> Option { + // A sentinel the caller can split on: `prepare_first_message_with_skills_index` + // owns the block's exact layout, so re-deriving it here would be a second + // copy of that layout. + const SENTINEL: &str = "\u{0}AIONUI_CONTENT\u{0}"; + let composed = inject_first_message_prefix(SENTINEL, manager, config).await; + match composed.strip_suffix(SENTINEL) { + // Nothing was prepended: the content came back untouched. + None => None, + Some(prefix) if prefix.trim().is_empty() => None, + Some(prefix) => Some(prefix.trim_end().to_owned()), + } +} + +fn is_light_mode(mode: &SkillDeliveryMode) -> bool { + matches!(mode, SkillDeliveryMode::Argv | SkillDeliveryMode::Protocol) +} + #[cfg(test)] mod tests { use super::*; @@ -82,8 +113,126 @@ mod tests { } } + fn skill_corpus(base: &std::path::Path) { + let auto = base.join("auto-inject"); + std::fs::create_dir_all(auto.join("cron")).unwrap(); + std::fs::write( + auto.join("cron").join("SKILL.md"), + "---\nname: cron\ndescription: Schedule stuff\n---\nBody.", + ) + .unwrap(); + } + + /// BOTH layer-1 modes must be light. `Protocol` used to fall to the heavy + /// branch because the old signal was a single bool: codex would then have + /// received a duplicate index on top of what its CLI already injects. + #[tokio::test] + async fn protocol_mode_is_light_just_like_argv() { + let tmp = TempDir::new().unwrap(); + skill_corpus(tmp.path()); + let _guard = EmptyBuiltinGuard::new(tmp.path()); + let mgr = test_mgr(tmp.path()); + + let out = inject_first_message_prefix( + "Do stuff", + &mgr, + InjectionConfig { + user_id: "system_default_user", + preset_context: Some("Custom rule"), + skills: &["cron".to_owned()], + delivery_mode: SkillDeliveryMode::Protocol, + }, + ) + .await; + + assert!(out.contains("Custom rule"), "preset context still ships in light mode"); + assert!( + !out.contains("Available Skills"), + "protocol delivery must not also inject an index: {out}" + ); + } + + /// The `injected` block must advertise both channels — this is the wiring + /// half of the truncation-is-safe argument. + #[tokio::test] + async fn injected_mode_carries_the_dual_channel_instructions() { + let tmp = TempDir::new().unwrap(); + skill_corpus(tmp.path()); + let _guard = EmptyBuiltinGuard::new(tmp.path()); + let mgr = test_mgr(tmp.path()); + + let out = inject_first_message_prefix( + "Hello", + &mgr, + InjectionConfig { + user_id: "system_default_user", + preset_context: None, + skills: &["cron".to_owned()], + delivery_mode: SkillDeliveryMode::Injected, + }, + ) + .await; + assert!(out.contains("Available Skills")); + assert!(out.contains("skills show")); + assert!(out.contains("[LOAD_SKILL:")); + } + + /// `compose_injected_prefix` must produce the block WITHOUT the caller's + /// content, and must not leak the sentinel it splits on. + #[tokio::test] + async fn compose_injected_prefix_returns_the_block_without_content() { + let tmp = TempDir::new().unwrap(); + skill_corpus(tmp.path()); + let _guard = EmptyBuiltinGuard::new(tmp.path()); + let mgr = test_mgr(tmp.path()); + + let prefix = compose_injected_prefix( + &mgr, + InjectionConfig { + user_id: "system_default_user", + preset_context: Some("Rule 1."), + skills: &["cron".to_owned()], + delivery_mode: SkillDeliveryMode::Injected, + }, + ) + .await + .expect("a conversation with rules and skills must produce a prefix"); + + assert!(prefix.contains("[Assistant Rules]")); + assert!(prefix.contains("Rule 1.")); + assert!(prefix.contains("Available Skills")); + assert!( + prefix.ends_with("[/Assistant Rules]"), + "trailing blank lines trimmed: {prefix:?}" + ); + assert!(!prefix.contains('\u{0}'), "the split sentinel must never escape"); + } + + /// Nothing to inject must be `None`, not an empty string: the caller uses it + /// to decide whether to touch the prompt at all. + #[tokio::test] + async fn compose_injected_prefix_is_none_when_there_is_nothing_to_inject() { + let tmp = TempDir::new().unwrap(); + let _guard = EmptyBuiltinGuard::new(tmp.path()); + let mgr = test_mgr(tmp.path()); + + assert!( + compose_injected_prefix( + &mgr, + InjectionConfig { + user_id: "system_default_user", + preset_context: None, + skills: &[], + delivery_mode: SkillDeliveryMode::Injected, + }, + ) + .await + .is_none() + ); + } + #[tokio::test] - async fn light_mode_with_preset_context() { + async fn argv_mode_is_light_and_only_carries_preset_context() { let tmp = TempDir::new().unwrap(); let mgr = test_mgr(tmp.path()); @@ -94,7 +243,7 @@ mod tests { user_id: "system_default_user", preset_context: Some("Be concise."), skills: &[], - native_skill_support: true, + delivery_mode: SkillDeliveryMode::Argv, }, ) .await; @@ -105,7 +254,7 @@ mod tests { } #[tokio::test] - async fn light_mode_empty_context_passes_through() { + async fn light_mode_with_no_context_passes_through() { let tmp = TempDir::new().unwrap(); let mgr = test_mgr(tmp.path()); @@ -116,7 +265,7 @@ mod tests { user_id: "system_default_user", preset_context: None, skills: &[], - native_skill_support: true, + delivery_mode: SkillDeliveryMode::Argv, }, ) .await; @@ -124,7 +273,7 @@ mod tests { } #[tokio::test] - async fn heavy_mode_no_skills_no_context_passes_through() { + async fn injected_mode_with_no_skills_and_no_context_passes_through() { let tmp = TempDir::new().unwrap(); let _guard = EmptyBuiltinGuard::new(tmp.path()); let mgr = test_mgr(tmp.path()); @@ -136,7 +285,7 @@ mod tests { user_id: "system_default_user", preset_context: None, skills: &[], - native_skill_support: false, + delivery_mode: SkillDeliveryMode::Injected, }, ) .await; @@ -144,7 +293,7 @@ mod tests { } #[tokio::test] - async fn heavy_mode_with_preset_context_no_skills() { + async fn injected_mode_with_preset_context_and_no_skills() { let tmp = TempDir::new().unwrap(); let _guard = EmptyBuiltinGuard::new(tmp.path()); let mgr = test_mgr(tmp.path()); @@ -156,7 +305,7 @@ mod tests { user_id: "system_default_user", preset_context: Some("Rule 1."), skills: &[], - native_skill_support: false, + delivery_mode: SkillDeliveryMode::Injected, }, ) .await; @@ -167,7 +316,7 @@ mod tests { } #[tokio::test] - async fn heavy_mode_with_resolved_skills_injects_index() { + async fn injected_mode_with_resolved_skills_injects_the_index() { // Set up a builtin skills dir with two skills; pass only one in `skills`. let tmp = TempDir::new().unwrap(); let auto = tmp.path().join("auto-inject"); @@ -193,7 +342,7 @@ mod tests { user_id: "system_default_user", preset_context: None, skills: &["cron".to_owned()], - native_skill_support: false, + delivery_mode: SkillDeliveryMode::Injected, }, ) .await; @@ -203,7 +352,7 @@ mod tests { } #[tokio::test] - async fn native_support_uses_light_mode_even_with_skills() { + async fn a_layer_one_vendor_stays_light_even_with_skills() { let tmp = TempDir::new().unwrap(); let _guard = EmptyBuiltinGuard::new(tmp.path()); let mgr = test_mgr(tmp.path()); @@ -215,7 +364,7 @@ mod tests { user_id: "system_default_user", preset_context: Some("Custom rule"), skills: &["cron".to_owned()], - native_skill_support: true, + delivery_mode: SkillDeliveryMode::Argv, }, ) .await; diff --git a/crates/aionui-ai-agent/src/capability/skill_manager/mod.rs b/crates/aionui-ai-agent/src/capability/skill_manager/mod.rs index 6b6addd13..a175af114 100644 --- a/crates/aionui-ai-agent/src/capability/skill_manager/mod.rs +++ b/crates/aionui-ai-agent/src/capability/skill_manager/mod.rs @@ -238,6 +238,60 @@ impl AcpSkillManager { } } + /// Resolve `names` to their REAL on-disk skill directories. + /// + /// Deliberately routed through the same `materialize_skills_for_agent_*` + /// resolution the conversation service uses to build the view directory, so + /// the directories a CLI gets allow-listed and the directories the view + /// links to agree by construction rather than by coincidence. + /// + /// Unknown or unreadable names are skipped (the helper warns); the result is + /// sorted by name for a deterministic spawn argv. + pub async fn resolve_skill_dirs_for_user( + &self, + user_id: &str, + names: &[String], + ) -> Vec { + if names.is_empty() { + return Vec::new(); + } + let resolved = match &self.skill_repo { + Some(repo) => { + aionui_extension::materialize_skills_for_agent_with_repo_for_user( + &self.paths, + repo.as_ref(), + user_id, + "skill-delivery", + names, + ) + .await + } + // Same dev/no-DB fallback as `list_available_skills_for_user`, which + // is not per-user isolated; production always injects the repo. + None => { + tracing::warn!( + user_id, + "skill_repo not configured; resolving skill dirs without user scoping \ + (must not happen in production)" + ); + aionui_extension::materialize_skills_for_agent(&self.paths, "skill-delivery", names).await + } + }; + match resolved { + Ok(list) => list + .into_iter() + .map(|skill| aionui_session::SkillDirSpec { + name: skill.name, + path: skill.source_path.to_string_lossy().into_owned(), + }) + .collect(), + Err(e) => { + warn!(error = %e, "resolve_skill_dirs_for_user failed; delivering no skill directories"); + Vec::new() + } + } + } + /// Return the current skill index without re-scanning. pub async fn get_skills_index(&self) -> Vec { let cache = self.cache.read().await; diff --git a/crates/aionui-ai-agent/src/capability/skill_manager/prompt_builder.rs b/crates/aionui-ai-agent/src/capability/skill_manager/prompt_builder.rs index 35d05e87a..c8c03cac6 100644 --- a/crates/aionui-ai-agent/src/capability/skill_manager/prompt_builder.rs +++ b/crates/aionui-ai-agent/src/capability/skill_manager/prompt_builder.rs @@ -1,23 +1,83 @@ use super::{SkillDefinition, SkillIndex}; -/// Build a formatted text block listing available skills for injection. +/// Per-skill description budget, in CHARS. /// -/// The output includes skill names with descriptions and instructions -/// on how to request loading via `[LOAD_SKILL: name]`. +/// The injection block was measured at roughly 1545 characters with a SINGLE +/// 687-character description accounting for 47% of it. 200 keeps every compliant +/// description intact (the well-behaved builtins sit at 133-142) and cuts only +/// the genuinely oversized ones. +/// +/// Truncating is safe only BECAUSE channel A exists: an agent that sees a cut +/// description and suspects the skill is relevant can fetch the full text with +/// `skills show`. Without that escape hatch, truncation would degrade the +/// agent's ability to decide when a skill applies. +const DESCRIPTION_CHAR_BUDGET: usize = 200; + +fn truncate_description(description: &str) -> String { + // chars(), not bytes: a byte slice would split a multi-byte codepoint and + // produce invalid UTF-8 for any non-ASCII description. + if description.chars().count() <= DESCRIPTION_CHAR_BUDGET { + return description.to_owned(); + } + let mut out: String = description.chars().take(DESCRIPTION_CHAR_BUDGET).collect(); + out.push('…'); + out +} + +/// Build the skills index block injected for `injected`-mode agents. +/// +/// Two channels are offered and the AGENT chooses. We deliberately do not try to +/// predict whether it can execute commands: permission mode (plan / read-only) +/// is agent-side runtime state that no CLI capability query exposes. Channel B +/// requires no vendor capability at all, which is what makes it a true fallback, +/// and channel A is listed first because it is a normal tool call rather than +/// text-matching plus an extra turn. pub fn build_skills_index_text(skills: &[SkillIndex]) -> String { if skills.is_empty() { return String::new(); } - let mut lines = Vec::with_capacity(skills.len() + 4); + // Sorted: the upstream discovery returns from a HashMap, so without this the + // same conversation could be injected a differently-ordered block on each + // open -- churning the agent's context for no reason and defeating any + // prefix caching. + let mut ordered: Vec<&SkillIndex> = skills.iter().collect(); + ordered.sort_by(|a, b| a.name.cmp(&b.name)); + + let mut lines = Vec::with_capacity(ordered.len() + 5); lines.push("## Available Skills".to_string()); lines.push(String::new()); - lines.push("To load a skill, include `[LOAD_SKILL: skill-name]` in your response.".to_string()); - lines.push(String::new()); - - for skill in skills { - lines.push(format!("- **{}**: {}", skill.name, skill.description)); + for skill in ordered { + lines.push(format!( + "- **{}**: {}", + skill.name, + truncate_description(&skill.description) + )); } + lines.push(String::new()); + // These command lines are the CONTRACT, not prose: both subcommands read their + // arguments as a JSON object on STDIN and reject positional arguments outright + // (`aionui-app/src/cli.rs` declares them as argument-less variants). An earlier + // wording taught `skills show `, which cost live agents three to five + // failed tool calls each before they guessed the real shape -- and `--help` did + // not mention stdin either, so the obvious self-service path was a dead end. + // `skills_cli_commands_in_the_index_are_parseable` in `aionui-app` pins the two + // sides together so they cannot drift apart again. + lines.push( + "To get a skill's full content, prefer running \ + `printf '%s' '{\"name\":\"\"}' | \"$AIONUI_HELPER_BIN\" skills show` — it also \ + returns the skill's absolute directory, and \ + `printf '%s' '{\"path\":\"/\"}' | \"$AIONUI_HELPER_BIN\" skills cat` \ + reads its supplementary files. Both read their arguments as a JSON object on stdin and \ + take no positional arguments; run `\"$AIONUI_HELPER_BIN\" skills capabilities` for the \ + full contract." + .to_string(), + ); + lines.push( + "If you cannot execute commands, output `[LOAD_SKILL: ]` in your response instead \ + and the content will be provided on the next turn." + .to_string(), + ); lines.join("\n") } @@ -145,11 +205,90 @@ mod tests { ]; let text = build_skills_index_text(&skills); assert!(text.contains("## Available Skills")); - assert!(text.contains("[LOAD_SKILL: skill-name]")); assert!(text.contains("- **review**: Code review")); assert!(text.contains("- **debug**: Debugging helper")); } + /// 200 CHARS, not bytes: a byte slice would cut a Chinese description + /// mid-codepoint and emit invalid UTF-8. + #[test] + fn a_long_description_is_truncated_at_200_chars_on_a_char_boundary() { + let text = build_skills_index_text(&[SkillIndex { + name: "verbose".into(), + description: "重".repeat(400), + }]); + let line = text.lines().find(|line| line.contains("verbose")).unwrap(); + let rendered = line.split_once(": ").unwrap().1; + assert_eq!(rendered.chars().count(), 201, "200 chars plus the ellipsis"); + assert!(rendered.ends_with('…')); + } + + #[test] + fn a_compliant_description_is_kept_verbatim() { + let text = build_skills_index_text(&[SkillIndex { + name: "cron".into(), + description: "Scheduled task management.".into(), + }]); + assert!(text.contains("- **cron**: Scheduled task management.")); + assert!(!text.contains('…')); + } + + /// A description exactly at the budget must NOT gain an ellipsis -- an + /// off-by-one here would mark compliant skills as truncated. + #[test] + fn a_description_exactly_at_the_budget_is_not_truncated() { + let text = build_skills_index_text(&[SkillIndex { + name: "edge".into(), + description: "x".repeat(200), + }]); + assert!(!text.contains('…'), "200 is within budget, not over it"); + } + + /// Truncation is only SAFE because channel A exists, so the block must + /// advertise both channels -- with the command first, since it is a normal + /// tool call rather than text-matching plus an extra turn. + #[test] + fn the_index_block_advertises_both_channels_with_the_command_first() { + let text = build_skills_index_text(&[SkillIndex { + name: "cron".into(), + description: "d".into(), + }]); + let command_at = text.find("skills show").expect("channel A must be advertised"); + let protocol_at = text.find("[LOAD_SKILL:").expect("channel B must stay as the fallback"); + assert!(command_at < protocol_at, "channel A is the preferred path"); + assert!(text.contains("$AIONUI_HELPER_BIN")); + assert!(text.contains("skills cat"), "supplementary files need their own hint"); + } + + /// Discovery upstream returns from a HashMap, so an unsorted block would vary + /// between opens of the SAME conversation -- churning context and defeating + /// prefix caching. + #[test] + fn the_index_is_ordered_regardless_of_input_order() { + let forward = build_skills_index_text(&[ + SkillIndex { + name: "alpha".into(), + description: "a".into(), + }, + SkillIndex { + name: "zeta".into(), + description: "z".into(), + }, + ]); + let reversed = build_skills_index_text(&[ + SkillIndex { + name: "zeta".into(), + description: "z".into(), + }, + SkillIndex { + name: "alpha".into(), + description: "a".into(), + }, + ]); + assert_eq!(forward, reversed); + assert!(forward.find("alpha").unwrap() < forward.find("zeta").unwrap()); + } + // ----------------------------------------------------------------------- // First message preparation // ----------------------------------------------------------------------- @@ -248,7 +387,13 @@ mod tests { assert!(result.starts_with("Base prompt")); assert!(result.contains("## Available Skills")); assert!(result.contains("- **helper**: A helper skill")); - assert!(result.contains("[LOAD_SKILL: skill-name]")); + // The placeholder text changed from `skill-name` to `` when the + // block gained its second channel. Asserting the PROTOCOL MARKER instead + // of the exact placeholder keeps the meaningful part of the check -- + // that this builder still carries a load instruction -- without pinning + // wording that channel A's arrival legitimately rewrote. + assert!(result.contains("[LOAD_SKILL:")); + assert!(result.contains("skills show"), "both channels travel with the index"); } #[test] diff --git a/crates/aionui-ai-agent/src/factory/acp.rs b/crates/aionui-ai-agent/src/factory/acp.rs index c2ff58554..10023c9a1 100644 --- a/crates/aionui-ai-agent/src/factory/acp.rs +++ b/crates/aionui-ai-agent/src/factory/acp.rs @@ -112,6 +112,14 @@ pub(super) async fn build( if let Some(backend_label) = config.backend.as_deref() && matches!(route_for_backend(Some(backend_label)), BackendRoute::DirectCli) { + let delivery = crate::factory::resolve_skill_delivery( + deps.as_ref(), + &ctx.user_id, + &ctx.conversation_id, + &config.skills, + &meta, + ) + .await; let instance = crate::session_agent::build_session_instance( backend_label, crate::session_agent::SessionBuildInputs { @@ -120,6 +128,7 @@ pub(super) async fn build( workspace: ctx.workspace.clone(), config: &config, metadata: &meta, + skill_delivery: delivery, session_snapshot: build_context.session_snapshot.as_ref(), backend_session_id: build_context.session_id.clone(), mcp_server_repo: deps.mcp_server_repo.as_ref(), @@ -178,6 +187,24 @@ pub(super) async fn build( runtime_env: &ctx.runtime_env, }, ); + + // Skill delivery args for the ACP lane. These come straight from + // `agent_metadata`, so giving a newly probed vendor an equivalent flag is a + // one-row config change with no code change and no release. + // + // Appended AFTER the launch policy so a vendor's own configured args keep + // their position; the CLI we spawn here is the vendor binary itself, so its + // argv is exactly `agent_metadata.args` plus these. + let acp_delivery = crate::factory::resolve_skill_delivery( + deps.as_ref(), + &ctx.user_id, + &ctx.conversation_id, + &config.skills, + &meta, + ) + .await; + command_spec.args.extend(acp_delivery.plan.extra_args.iter().cloned()); + let session_snapshot = build_context.session_snapshot; // Load user-configured MCP servers from the DB so they reach @@ -703,6 +730,7 @@ mod tests { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -895,6 +923,7 @@ mod tests { description: None, }], native_skills_dirs: None, + skill_delivery: None, behavior_policy: aionui_api_types::BehaviorPolicy::default(), yolo_id: None, sort_order: 0, diff --git a/crates/aionui-ai-agent/src/factory/acp_assembler.rs b/crates/aionui-ai-agent/src/factory/acp_assembler.rs index bc30e9481..f833db642 100644 --- a/crates/aionui-ai-agent/src/factory/acp_assembler.rs +++ b/crates/aionui-ai-agent/src/factory/acp_assembler.rs @@ -170,6 +170,7 @@ mod tests { args: vec![], env: vec![], native_skills_dirs: None, + skill_delivery: None, behavior_policy: aionui_api_types::BehaviorPolicy::default(), yolo_id: None, sort_order: 0, diff --git a/crates/aionui-ai-agent/src/factory/acp_launch_policy.rs b/crates/aionui-ai-agent/src/factory/acp_launch_policy.rs index 055966ab2..3e3d12352 100644 --- a/crates/aionui-ai-agent/src/factory/acp_launch_policy.rs +++ b/crates/aionui-ai-agent/src/factory/acp_launch_policy.rs @@ -124,6 +124,7 @@ mod tests { args: vec![], env: vec![], native_skills_dirs: None, + skill_delivery: None, behavior_policy: aionui_api_types::BehaviorPolicy::default(), yolo_id: Some("agent-full-access".into()), sort_order: 0, diff --git a/crates/aionui-ai-agent/src/factory/aionrs.rs b/crates/aionui-ai-agent/src/factory/aionrs.rs index 2f648b3ea..d4c09cde6 100644 --- a/crates/aionui-ai-agent/src/factory/aionrs.rs +++ b/crates/aionui-ai-agent/src/factory/aionrs.rs @@ -26,6 +26,25 @@ use crate::manager::aionrs::{AionrsAgentManager, sanitize_session_messages}; use crate::runtime_status::conversation_runtime_reporter; use crate::session_context::AionrsSessionBuildContext; use crate::types::{AionrsCompatOverrides, AionrsResolvedConfig}; + +/// Render this conversation's skills index, or `""` when there is nothing to add. +async fn skill_index_text(deps: &AgentFactoryDeps, user_id: &str, skills: &[String]) -> String { + let index = deps.skill_manager.discover_by_names_for_user(user_id, skills).await; + crate::capability::skill_manager::build_skills_index_text(&index) +} + +/// Append the index to the system prompt, keeping the assistant's own rules in +/// the leading position. +/// +/// Pure so the composition rules are testable without standing up a factory. +fn merge_skill_index_into_system_prompt(system_prompt: Option, index: &str) -> Option { + match (system_prompt, index.is_empty()) { + (prompt, true) => prompt, + (Some(prompt), false) => Some(format!("{prompt}\n\n{index}")), + (None, false) => Some(index.to_owned()), + } +} + pub(super) async fn build( deps: Arc, build_context: AionrsSessionBuildContext, @@ -39,9 +58,7 @@ pub(super) async fn build( // in aionrs's build_system_prompt). Mirrors the old architecture's // `init_history` injection of `[Assistant System Rules]`. // AionrsBuildExtra parses `skills` so Team preset snapshots preserve the - // target contract. Native skill materialization for Aionrs is tracked as a - // separate follow-up because this factory currently has no stable Aionrs - // skill-loading path. + // target contract. if let Some(rules) = overrides.preset_rules.take() { overrides.system_prompt = Some(match overrides.system_prompt.take() { Some(existing) => format!("{existing}\n\n{rules}"), @@ -49,6 +66,31 @@ pub(super) async fn build( }); } + // Fold the skills index into the system prompt. + // + // aionrs is a layer-2 vendor with no prompt pipeline: index injection has + // always lived in the ACP hook, which this factory never runs. Its skills + // reached it only through the workspace `.aionrs/skills` links that this + // refactor removes, so without this the backend would lose skills outright. + // `system_prompt` is its one always-present channel. + // + // Deliberately NOT routed through `resolve_skill_delivery` (unlike the ACP + // and Antigravity lanes), and it emits no "resolved delivery plan" anchor + // log. That is intentional, not an oversight: aionrs is an in-process agent + // compiled into aioncore, so it has neither a launch-argument channel (argv) + // nor a wire protocol (protocol) -- `system_prompt` injection is the only + // delivery it can physically perform. Its `skill_delivery` column therefore + // has no applicable value other than `injected` (its NULL default), so + // reading it would only ever fetch a metadata row and compute a plan this + // lane cannot use. If aionrs ever gains a real native skill channel (e.g. + // reading the session-skills view directory directly), that is a change in + // the aionrs crate itself, and this is where it would re-join the shared + // delivery decision. + overrides.system_prompt = merge_skill_index_into_system_prompt( + overrides.system_prompt.take(), + &skill_index_text(&deps, &ctx.user_id, &resolved_skills).await, + ); + let mut extra_mcp_servers = resolve_mcp_servers(&overrides); if let Some(repo) = deps.mcp_server_repo.as_ref() { for (name, config) in load_user_mcp_servers( @@ -2255,6 +2297,53 @@ mod tests { assert!(resolve_bedrock_config(Some("not-json")).is_none()); } + /// aionrs is layer 2 and has no prompt pipeline, so `system_prompt` is its + /// only injection channel. Its skills used to arrive solely through the + /// workspace `.aionrs/skills` links this refactor removes. + #[test] + fn the_skills_index_is_appended_after_the_assistant_rules() { + let index = crate::capability::skill_manager::build_skills_index_text(&[ + crate::capability::skill_manager::SkillIndex { + name: "cron".into(), + description: "Schedule stuff".into(), + }, + ]); + let merged = + merge_skill_index_into_system_prompt(Some("Be concise.".to_owned()), &index).expect("a prompt exists"); + + assert!( + merged.starts_with("Be concise."), + "assistant rules keep the lead: {merged}" + ); + assert!(merged.contains("## Available Skills")); + assert!(merged.contains("- **cron**: Schedule stuff")); + assert!(merged.contains("skills show"), "channel A first"); + assert!(merged.contains("[LOAD_SKILL:"), "channel B as the fallback"); + } + + #[test] + fn no_skills_leaves_the_system_prompt_untouched() { + assert_eq!( + merge_skill_index_into_system_prompt(Some("Be concise.".to_owned()), ""), + Some("Be concise.".to_owned()) + ); + assert_eq!(merge_skill_index_into_system_prompt(None, ""), None); + } + + /// An assistant with no rules but with skills must still get the index — + /// otherwise a default assistant silently loses every skill. + #[test] + fn skills_alone_still_produce_a_system_prompt() { + let index = crate::capability::skill_manager::build_skills_index_text(&[ + crate::capability::skill_manager::SkillIndex { + name: "cron".into(), + description: "d".into(), + }, + ]); + let merged = merge_skill_index_into_system_prompt(None, &index).expect("skills alone produce a prompt"); + assert!(merged.starts_with("## Available Skills")); + } + #[test] fn preset_rules_merged_into_system_prompt_when_no_existing() { let json = serde_json::json!({ diff --git a/crates/aionui-ai-agent/src/factory/antigravity.rs b/crates/aionui-ai-agent/src/factory/antigravity.rs index e0df5e991..5e4b4475e 100644 --- a/crates/aionui-ai-agent/src/factory/antigravity.rs +++ b/crates/aionui-ai-agent/src/factory/antigravity.rs @@ -123,6 +123,25 @@ pub(super) async fn build( let mut runtime_env = ctx.runtime_env.clone(); runtime_env.extend(hook_env); + let mut delivery = crate::factory::resolve_skill_delivery( + deps.as_ref(), + &ctx.user_id, + &ctx.conversation_id, + &config.skills, + &meta, + ) + .await; + // agy has no prompt pipeline, so compose the rules block here and let the + // backend prepend it to the first `-p` invocation. + delivery.injected_prefix = crate::factory::compose_injected_prefix_for( + deps.as_ref(), + &ctx.user_id, + config.preset_context.as_deref(), + &config.skills, + &delivery.plan.mode, + ) + .await; + let instance = crate::session_agent::build_antigravity_instance( crate::session_agent::SessionBuildInputs { conversation_id: ctx.conversation_id.clone(), @@ -130,6 +149,7 @@ pub(super) async fn build( workspace: ctx.workspace.clone(), config: &config, metadata: &meta, + skill_delivery: delivery, session_snapshot: build_context.session_snapshot.as_ref(), backend_session_id: build_context.session_id.clone(), mcp_server_repo: deps.mcp_server_repo.as_ref(), diff --git a/crates/aionui-ai-agent/src/factory/mod.rs b/crates/aionui-ai-agent/src/factory/mod.rs index 829b72651..03ad8fb04 100644 --- a/crates/aionui-ai-agent/src/factory/mod.rs +++ b/crates/aionui-ai-agent/src/factory/mod.rs @@ -73,6 +73,113 @@ pub fn build_agent_factory(deps: AgentFactoryDeps) -> AgentFactory { }) } +/// This conversation's skill delivery, resolved once per session build. +/// +/// `pub` because it travels through the `pub` `SessionBuildInputs`. `Default` is +/// the "deliver nothing" shape, which is what a test that does not exercise skill +/// delivery wants. +#[derive(Default)] +pub struct ResolvedSkillDelivery { + /// Already-substituted launch args / protocol root for this vendor. + pub plan: crate::skill_delivery_plan::SkillDeliveryPlan, + /// The conversation's skills as real source directories. Carried into + /// `SessionInit` for backends that need name+path without touching the + /// workspace. + pub skill_dirs: Vec, + /// The composed `[Assistant Rules]` block for an `injected`-mode vendor. + /// + /// Populated only by factory branches whose backend has NO prompt pipeline of + /// its own — agy and aionrs. The ACP lane leaves this `None` because its + /// `SessionNewPreludeHook` composes the same block at prompt time (through + /// the same function, so the wording cannot diverge); computing it here too + /// would be dead work. + pub injected_prefix: Option, +} + +/// Resolve the per-vendor delivery for one session build. +/// +/// Shared by every factory branch so the decision is made in ONE place: which +/// mode applies, which paths get substituted, and what gets logged. Splitting it +/// per branch is how the old `native_skills_dirs` logic drifted between the +/// create path and the build path. +pub(crate) async fn resolve_skill_delivery( + deps: &AgentFactoryDeps, + user_id: &str, + conversation_id: &str, + skills: &[String], + metadata: &aionui_api_types::AgentMetadata, +) -> ResolvedSkillDelivery { + let skill_dirs = deps.skill_manager.resolve_skill_dirs_for_user(user_id, skills).await; + + // A rejected id yields no view path. `plan_skill_delivery` then contributes + // no plugin flag rather than a half-substituted one. + let view_dir = aionui_extension::skill_view::view_dir(&deps.data_dir, user_id, conversation_id) + .ok() + .map(|path| path.to_string_lossy().into_owned()); + let view_skills_dir = aionui_extension::skill_view::view_skills_dir(&deps.data_dir, user_id, conversation_id) + .ok() + .map(|path| path.to_string_lossy().into_owned()); + + let plan = crate::skill_delivery_plan::plan_skill_delivery(crate::skill_delivery_plan::SkillDeliveryPlanInput { + delivery: metadata.skill_delivery.clone(), + view_dir, + view_skills_dir, + skill_dirs: skill_dirs.clone(), + }); + + for placeholder in &plan.unknown_placeholders { + tracing::warn!( + conversation_id, + backend = metadata.backend.as_deref().unwrap_or("unknown"), + placeholder = %placeholder, + "skill_delivery: unrecognized placeholder kept verbatim in the spawn args" + ); + } + // `info`, not `debug`: this is the anchor for "why did this vendor not pick + // up native skills" in a production log at default level. + tracing::info!( + conversation_id, + backend = metadata.backend.as_deref().unwrap_or("unknown"), + mode = ?plan.mode, + skills = skill_dirs.len(), + // Count only -- a full path list would put user directory names in logs. + delivery_args = plan.extra_args.len(), + protocol_root = plan.protocol_skills_root.is_some(), + "skill_delivery: resolved delivery plan for session" + ); + + ResolvedSkillDelivery { + plan, + skill_dirs, + injected_prefix: None, + } +} + +/// Compose the `[Assistant Rules]` block for a backend with no prompt pipeline. +/// +/// Only agy and aionrs need this: index injection has always lived in the ACP +/// prompt pipeline, and those two backends never run it — which is why, before +/// this existed, an `injected`-mode agy or aionrs session received no skills +/// index at all and (for agy) not even its preset context. +pub(crate) async fn compose_injected_prefix_for( + deps: &AgentFactoryDeps, + user_id: &str, + preset_context: Option<&str>, + skills: &[String], + mode: &aionui_api_types::SkillDeliveryMode, +) -> Option { + crate::capability::first_message_injector::compose_injected_prefix( + &deps.skill_manager, + crate::capability::first_message_injector::InjectionConfig { + user_id, + preset_context, + skills, + delivery_mode: mode.clone(), + }, + ) + .await +} + async fn build_agent(deps: Arc, options: BuildTaskOptions) -> Result { let context = options.context; let ctx = FactoryContext::resolve(&context).await?; diff --git a/crates/aionui-ai-agent/src/lib.rs b/crates/aionui-ai-agent/src/lib.rs index fe5ab6e85..c9d10c4c4 100644 --- a/crates/aionui-ai-agent/src/lib.rs +++ b/crates/aionui-ai-agent/src/lib.rs @@ -30,6 +30,7 @@ pub(crate) mod services; pub mod session_agent; pub mod session_context; pub mod shared_kernel; +pub mod skill_delivery_plan; pub mod task_manager; pub mod terminal; pub mod types; diff --git a/crates/aionui-ai-agent/src/manager/acp/agent.rs b/crates/aionui-ai-agent/src/manager/acp/agent.rs index 57a42b8c2..6e77e9523 100644 --- a/crates/aionui-ai-agent/src/manager/acp/agent.rs +++ b/crates/aionui-ai-agent/src/manager/acp/agent.rs @@ -1796,6 +1796,7 @@ mod tests { args: vec![], env: vec![], native_skills_dirs: None, + skill_delivery: None, behavior_policy: BehaviorPolicy::default(), yolo_id: yolo_id.map(ToOwned::to_owned), sort_order: 0, diff --git a/crates/aionui-ai-agent/src/manager/acp/hooks.rs b/crates/aionui-ai-agent/src/manager/acp/hooks.rs index 19aa822e2..6268352c8 100644 --- a/crates/aionui-ai-agent/src/manager/acp/hooks.rs +++ b/crates/aionui-ai-agent/src/manager/acp/hooks.rs @@ -25,10 +25,19 @@ impl PreSendHook for SessionNewPreludeHook { user_id: &ctx.params.user_id, preset_context: ctx.params.preset_context.as_deref(), skills: &ctx.params.config.skills, - native_skill_support: metadata - .native_skills_dirs + // Read from `skill_delivery`, not `native_skills_dirs`. The old + // signal meant "declares a workspace skills directory", which is not + // the same as "can discover skills", and it is the field this + // refactor retires from delivery decisions entirely. + // + // A missing declaration falls to `injected`: the safe default, since + // an unprobed vendor getting the index still works while one wrongly + // marked layer-1 would get no skills at all. + delivery_mode: metadata + .skill_delivery .as_ref() - .is_some_and(|v: &Vec| !v.is_empty()), + .map(|delivery| delivery.mode.clone()) + .unwrap_or(aionui_api_types::SkillDeliveryMode::Injected), }; // inject_first_message_prefix currently swallows I/O errors and diff --git a/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs b/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs index 18e95c6e8..a9b5bd4a8 100644 --- a/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs +++ b/crates/aionui-ai-agent/src/manager/acp/mode_normalize.rs @@ -144,6 +144,7 @@ mod tests { args: vec![], env: vec![], native_skills_dirs: None, + skill_delivery: None, behavior_policy: BehaviorPolicy::default(), yolo_id: yolo_id.map(ToOwned::to_owned), sort_order: 3130, diff --git a/crates/aionui-ai-agent/src/registry.rs b/crates/aionui-ai-agent/src/registry.rs index 47928ba5a..1d3d70d22 100644 --- a/crates/aionui-ai-agent/src/registry.rs +++ b/crates/aionui-ai-agent/src/registry.rs @@ -677,6 +677,7 @@ fn agent_management_row(meta: AgentMetadata, reason: Option<&UnavailableReason>) args: meta.args, env: Vec::new(), native_skills_dirs: meta.native_skills_dirs, + skill_delivery: meta.skill_delivery, behavior_policy: meta.behavior_policy, yolo_id: meta.yolo_id, config_options: handshake.config_options.clone(), @@ -733,6 +734,9 @@ fn same_runtime_availability_inputs(a: &AgentMetadata, b: &AgentMetadata) -> boo && a.args == b.args && json_values_equal(&a.env, &b.env) && a.native_skills_dirs == b.native_skills_dirs + // Spawn-relevant, exactly like `args`: an `argv`-mode row appends launch + // flags, so a change here must not reuse a snapshot taken without them. + && a.skill_delivery == b.skill_delivery && json_values_equal(&a.behavior_policy, &b.behavior_policy) && a.yolo_id == b.yolo_id } @@ -786,6 +790,7 @@ fn decode_row( let args = decode_json_field::>(row.args.as_deref(), "args").unwrap_or_default(); let env = decode_json_field::>(row.env.as_deref(), "env").unwrap_or_default(); let native_skills_dirs = decode_json_field::>(row.native_skills_dirs.as_deref(), "native_skills_dirs"); + let skill_delivery = Some(decode_skill_delivery(&row.id, row.skill_delivery.as_deref())); let behavior_policy = decode_json_field(row.behavior_policy.as_deref(), "behavior_policy").unwrap_or_else(BehaviorPolicy::default); @@ -836,6 +841,7 @@ fn decode_row( args, env, native_skills_dirs, + skill_delivery, behavior_policy, yolo_id: row.yolo_id, sort_order: row.sort_order, @@ -1408,6 +1414,40 @@ pub(crate) fn guidance_for_snapshot_error_code(error_code: &str) -> &'static str } } +/// Decode `agent_metadata.skill_delivery`, deliberately NOT through +/// [`decode_json_field`]. +/// +/// That helper logs one "failed to decode JSON column" line for every problem, +/// which would merge two situations that need opposite responses: +/// * an unknown `mode` means a NEWER registry wrote this row — expected, the +/// fix is to upgrade, and the delivery still degrades safely to `injected`; +/// * malformed JSON means damaged data — an anomaly worth investigating. +/// +/// Sharing one line makes a registry rollout problem impossible to triage, so +/// each gets its own message and the unknown-mode one carries the actual value. +fn decode_skill_delivery(agent_id: &str, raw: Option<&str>) -> aionui_api_types::SkillDelivery { + match aionui_api_types::parse_skill_delivery(raw) { + aionui_api_types::SkillDeliveryParse::Ok(delivery) => delivery, + aionui_api_types::SkillDeliveryParse::UnknownMode { raw_mode, delivery } => { + warn!( + agent_id, + unknown_mode = %raw_mode, + "agent_metadata.skill_delivery: unrecognized mode from a newer registry; \ + falling back to injected" + ); + delivery + } + aionui_api_types::SkillDeliveryParse::Malformed { error } => { + warn!( + agent_id, + error = %error, + "agent_metadata.skill_delivery: malformed JSON; falling back to injected" + ); + aionui_api_types::SkillDelivery::injected_default() + } + } +} + fn decode_json_field(raw: Option<&str>, field: &str) -> Option { raw.and_then(|s| match serde_json::from_str(s) { Ok(v) => Some(v), @@ -2095,6 +2135,7 @@ mod tests { args: None, env: None, native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -2142,6 +2183,7 @@ mod tests { args: None, env: None, native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -2198,6 +2240,7 @@ mod tests { args: None, env: Some(r#"[{"name":"BASE","value":"seed","description":""}]"#.to_string()), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, diff --git a/crates/aionui-ai-agent/src/registry_tests.rs b/crates/aionui-ai-agent/src/registry_tests.rs index dd52e82a0..573ecf195 100644 --- a/crates/aionui-ai-agent/src/registry_tests.rs +++ b/crates/aionui-ai-agent/src/registry_tests.rs @@ -32,6 +32,7 @@ async fn probe_resolved_command_keeps_bridge_but_version_probe_targets_primary_c args: vec![], env: vec![], native_skills_dirs: None, + skill_delivery: None, behavior_policy: BehaviorPolicy::default(), yolo_id: None, sort_order: 0, @@ -93,6 +94,7 @@ fn probe_resolved_command_requires_primary_binary_for_builtin_managed_claude() { args: vec![], env: vec![], native_skills_dirs: None, + skill_delivery: None, behavior_policy: BehaviorPolicy::default(), yolo_id: None, sort_order: 0, @@ -142,6 +144,7 @@ fn probe_resolved_command_requires_primary_binary_for_builtin_managed_codex() { args: vec![], env: vec![], native_skills_dirs: None, + skill_delivery: None, behavior_policy: BehaviorPolicy::default(), yolo_id: None, sort_order: 0, @@ -189,6 +192,7 @@ async fn management_rows_derive_missing_diagnostics_from_probe_reason() { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -268,6 +272,7 @@ async fn builtin_non_codex_with_broken_wrapper_is_not_installed() { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -328,6 +333,7 @@ async fn management_rows_mark_installed_agents_without_health_check_unchecked() args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -421,6 +427,7 @@ async fn management_rows_project_runtime_catalogs_from_agent_metadata() { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -519,6 +526,7 @@ fn upsert_script_agent_params<'a>( args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, diff --git a/crates/aionui-ai-agent/src/services/availability/mod.rs b/crates/aionui-ai-agent/src/services/availability/mod.rs index 0a79a053a..cbe9a2ded 100644 --- a/crates/aionui-ai-agent/src/services/availability/mod.rs +++ b/crates/aionui-ai-agent/src/services/availability/mod.rs @@ -485,6 +485,7 @@ mod tests { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -559,6 +560,7 @@ mod tests { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -641,6 +643,7 @@ mod tests { args: vec!["--yes".into(), "@agentclientprotocol/claude-agent-acp@0.58.1".into()], env: vec![], native_skills_dirs: Some(vec![".claude/skills".into()]), + skill_delivery: None, behavior_policy: BehaviorPolicy::default(), yolo_id: Some("bypassPermissions".into()), sort_order: 3100, @@ -721,6 +724,7 @@ mod tests { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -836,6 +840,7 @@ mod tests { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -907,6 +912,7 @@ mod tests { args: vec![], env: vec![], native_skills_dirs: None, + skill_delivery: None, behavior_policy: BehaviorPolicy::default(), yolo_id: None, sort_order: 0, diff --git a/crates/aionui-ai-agent/src/services/custom.rs b/crates/aionui-ai-agent/src/services/custom.rs index 01a0627e7..16251adfa 100644 --- a/crates/aionui-ai-agent/src/services/custom.rs +++ b/crates/aionui-ai-agent/src/services/custom.rs @@ -184,6 +184,7 @@ impl AgentService { args: Some(&args_json), env: Some(&env_json), native_skills_dirs: native_skills_dirs_json.as_deref(), + skill_delivery: None, behavior_policy: behavior_policy_json.as_deref(), yolo_id: advanced.yolo_id.as_deref(), agent_capabilities: None, diff --git a/crates/aionui-ai-agent/src/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index bad36e15b..9c62c546d 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -1502,6 +1502,11 @@ pub struct SessionBuildInputs<'a> { /// generic alias resumes by handing the raw alias to the backend (claude rejects /// an unknown permission-mode id; codex gets a non-native mode → wrong policy). pub metadata: &'a aionui_api_types::AgentMetadata, + /// This conversation's resolved skill delivery: the already-substituted + /// launch flags for an `argv` vendor, the skills root for a `protocol` one, + /// and the real skill source dirs. Resolved by the factory so this function + /// stays free of skill-resolution I/O. + pub skill_delivery: crate::factory::ResolvedSkillDelivery, /// The persisted runtime snapshot, when present. Its `current_mode_id` / /// `current_model_id` are the interactive-switch-persisted selections and take /// precedence over the create-time `config` values — the same precedence @@ -1733,6 +1738,7 @@ pub async fn build_antigravity_instance( workspace, config, metadata, + skill_delivery, session_snapshot, backend_session_id, mcp_server_repo, @@ -1775,7 +1781,19 @@ pub async fn build_antigravity_instance( let init = SessionInit { mcp_servers, skills: config.skills.clone(), - preset_context: config.preset_context.clone(), + // The COMPOSED block (preset context + skills index + dual-channel + // instructions), not the raw preset context: agy has no prompt pipeline, + // so this is its only injection channel. Falls back to the raw context so + // a layer-1 agy (should one ever exist) still gets its assistant rules. + preset_context: skill_delivery + .injected_prefix + .clone() + .or_else(|| config.preset_context.clone()), + // agy is a layer-2 vendor, so no protocol root. The skill dirs still + // travel: agy needs name+path to build its slash-command list, which it + // used to get by scanning the workspace. + skill_view_skills_dir: None, + skill_dirs: skill_delivery.skill_dirs.clone(), session_snapshot: None, resume: matches!(spec, aionui_session::SessionSpec::Resume { .. }), }; @@ -1790,6 +1808,9 @@ pub async fn build_antigravity_instance( // installs themselves, so there is no bundled path to resolve and the // backend always spawns the `agy` on PATH. cli_program: None, + // agy is layer 2, so this carries only the allow-list entries (one + // `--add-dir` per enabled skill) and never a plugin flag. + extra_args: skill_delivery.plan.extra_args.clone(), ..Default::default() }; session_config.spawn_env = assemble_spawn_env(&metadata.env, runtime_env); @@ -1845,6 +1866,7 @@ pub async fn build_session_instance( workspace, config, metadata, + skill_delivery, session_snapshot, backend_session_id, mcp_server_repo, @@ -1902,6 +1924,11 @@ pub async fn build_session_instance( mcp_servers, skills: config.skills.clone(), preset_context: config.preset_context.clone(), + // Layer 1. `Some` only for a protocol vendor (codex): the backend has to + // send the skills root itself. An argv vendor (claude) gets its flags + // through `extra_args` below instead. + skill_view_skills_dir: skill_delivery.plan.protocol_skills_root.clone(), + skill_dirs: skill_delivery.skill_dirs.clone(), // acp/codex resume via SessionSpec::Resume; no in-band snapshot needed. session_snapshot: None, resume: matches!(spec, SessionSpec::Resume { .. }), @@ -1923,6 +1950,17 @@ pub async fn build_session_instance( // keeps the bare name so the spawn error stays diagnosable. Detection // (cli_probe) stays PATH-only and is unaffected. cli_program: resolve_session_cli_program(backend_label, metadata), + // Layer 1 (argv). `--plugin-dir ` plus one `--add-dir ` per + // enabled skill, already substituted by the delivery plan. Empty for a + // non-argv vendor or an empty snapshot. + // + // Deliberately routed through `extra_args` rather than hard-coded in + // `build_claude_init_args`: that makes claude's layer-1 delivery + // DATA-driven like every ACP vendor's, so a flag change is a registry + // row rather than a code change. claude's builder positions its own init + // flags first and appends these after, with no de-duplication, so the + // repeated `--add-dir` survives intact (probe-verified). + extra_args: skill_delivery.plan.extra_args.clone(), ..Default::default() }; @@ -4764,6 +4802,7 @@ mod build_mapping_tests { args: vec![], env: vec![], native_skills_dirs: None, + skill_delivery: None, behavior_policy: BehaviorPolicy::default(), yolo_id: yolo_id.map(ToOwned::to_owned), sort_order: 0, @@ -4973,6 +5012,9 @@ mod build_mapping_tests { .into_owned(), config: &config, metadata: &metadata, + // This test is about MCP injection; skill delivery contributes + // nothing so the argv it asserts on stays unchanged. + skill_delivery: Default::default(), session_snapshot: None, backend_session_id: None, mcp_server_repo: Some(&repo), diff --git a/crates/aionui-ai-agent/src/skill_delivery_plan.rs b/crates/aionui-ai-agent/src/skill_delivery_plan.rs new file mode 100644 index 000000000..235d68464 --- /dev/null +++ b/crates/aionui-ai-agent/src/skill_delivery_plan.rs @@ -0,0 +1,302 @@ +//! Turn a vendor's `skill_delivery` declaration plus this conversation's +//! resolved skills into concrete launch arguments / protocol parameters. +//! +//! Pure and syscall-free on purpose: the substitution rules are the part most +//! likely to go subtly wrong (wrong root handed to the wrong vendor, an +//! allow-list that silently collapses to one entry), so they are unit-testable +//! in isolation and the backends only ever see already-substituted strings. + +use aionui_api_types::{SkillDelivery, SkillDeliveryMode}; +pub use aionui_session::SkillDirSpec; + +/// The plugin root — `.claude-plugin/plugin.json` lives directly under it. +const PLACEHOLDER_VIEW_DIR: &str = "{skill_view_dir}"; +/// The skills root — `{name}/SKILL.md` lives directly under it. +const PLACEHOLDER_VIEW_SKILLS_DIR: &str = "{skill_view_skills_dir}"; +/// One skill's REAL source directory. Expanded once per enabled skill. +const PLACEHOLDER_SKILL_DIR: &str = "{skill_dir}"; + +pub struct SkillDeliveryPlanInput { + /// `None` = the column was NULL or unparseable, which means `injected`. + pub delivery: Option, + pub view_dir: Option, + pub view_skills_dir: Option, + /// This conversation's resolved skills, as REAL source directories. + pub skill_dirs: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillDeliveryPlan { + pub mode: SkillDeliveryMode, + /// Appended to `SessionConfig.extra_args`; already substituted. + pub extra_args: Vec, + /// `Some` only in protocol mode: the skills root to send to the CLI. + pub protocol_skills_root: Option, + /// Placeholders we did not recognize, kept verbatim in `extra_args`. + /// Surfaced so the caller can `warn` once instead of dropping a flag. + pub unknown_placeholders: Vec, +} + +/// "Deliver nothing", which is what the safe default mode with an empty snapshot +/// produces. Used by tests that do not exercise skill delivery. +impl Default for SkillDeliveryPlan { + fn default() -> Self { + Self { + mode: SkillDeliveryMode::Injected, + extra_args: Vec::new(), + protocol_skills_root: None, + unknown_placeholders: Vec::new(), + } + } +} + +pub fn plan_skill_delivery(input: SkillDeliveryPlanInput) -> SkillDeliveryPlan { + let delivery = input.delivery.unwrap_or_else(SkillDelivery::injected_default); + let mode = delivery.mode.clone(); + + // No skills means nothing to deliver. Registering an empty plugin root would + // still cost an always-on token line for zero skills. + if input.skill_dirs.is_empty() { + return SkillDeliveryPlan { + mode, + extra_args: Vec::new(), + protocol_skills_root: None, + unknown_placeholders: Vec::new(), + }; + } + + let mut unknown = Vec::new(); + let mut extra_args = Vec::new(); + + if mode == SkillDeliveryMode::Argv { + // Skipped entirely when the view path is unavailable (a rejected id): + // half-substituted args would point the CLI at a literal + // `{skill_view_dir}` directory, which is worse than no flag. + if input.view_dir.is_some() || input.view_skills_dir.is_some() { + for arg in &delivery.args { + extra_args.push(substitute_scalar( + arg, + input.view_dir.as_deref(), + input.view_skills_dir.as_deref(), + &mut unknown, + )); + } + } + } + + // Allow-listing is orthogonal to mode and does not depend on the view: it + // targets the real source dirs, so it survives a missing view directory. + if !delivery.allow_dir_args.is_empty() { + for skill in &input.skill_dirs { + for arg in &delivery.allow_dir_args { + extra_args.push(if arg.contains(PLACEHOLDER_SKILL_DIR) { + arg.replace(PLACEHOLDER_SKILL_DIR, &skill.path) + } else { + substitute_scalar( + arg, + input.view_dir.as_deref(), + input.view_skills_dir.as_deref(), + &mut unknown, + ) + }); + } + } + } + + let protocol_skills_root = match mode { + SkillDeliveryMode::Protocol => input.view_skills_dir.clone(), + _ => None, + }; + + unknown.sort(); + unknown.dedup(); + SkillDeliveryPlan { + mode, + extra_args, + protocol_skills_root, + unknown_placeholders: unknown, + } +} + +fn substitute_scalar( + arg: &str, + view_dir: Option<&str>, + view_skills_dir: Option<&str>, + unknown: &mut Vec, +) -> String { + let mut out = arg.to_owned(); + // Longest first: `{skill_view_dir}` is not a prefix of + // `{skill_view_skills_dir}`, but keeping the order explicit documents that + // the two are distinct roots rather than interchangeable. + if let Some(view_skills_dir) = view_skills_dir { + out = out.replace(PLACEHOLDER_VIEW_SKILLS_DIR, view_skills_dir); + } + if let Some(view_dir) = view_dir { + out = out.replace(PLACEHOLDER_VIEW_DIR, view_dir); + } + // A leftover `{...}` is a placeholder from a newer registry. Keep it + // verbatim rather than dropping the flag (which would silently change the + // spawn) and let the caller warn with the actual value. + if out.starts_with('{') && out.ends_with('}') { + unknown.push(out.clone()); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use aionui_api_types::{SkillDelivery, SkillDeliveryMode}; + + fn dirs() -> Vec { + vec![ + SkillDirSpec { + name: "cron".into(), + path: "/src/cron".into(), + }, + SkillDirSpec { + name: "pdf".into(), + path: "/src/pdf".into(), + }, + ] + } + + fn input(delivery: SkillDelivery) -> SkillDeliveryPlanInput { + SkillDeliveryPlanInput { + delivery: Some(delivery), + view_dir: Some("/data/session-skills/u/c".into()), + view_skills_dir: Some("/data/session-skills/u/c/skills".into()), + skill_dirs: dirs(), + } + } + + #[test] + fn argv_mode_substitutes_the_view_dir_and_expands_allow_dir_per_skill() { + let plan = plan_skill_delivery(input(SkillDelivery { + mode: SkillDeliveryMode::Argv, + args: vec!["--plugin-dir".into(), "{skill_view_dir}".into()], + allow_dir_args: vec!["--add-dir".into(), "{skill_dir}".into()], + method: None, + })); + + assert_eq!(plan.mode, SkillDeliveryMode::Argv); + assert_eq!( + plan.extra_args, + vec![ + "--plugin-dir", + "/data/session-skills/u/c", + // One expansion PER SKILL, and the REAL source dir rather than + // the view: a CLI that resolves symlinks to their canonical path + // would not match a view-directory entry. + "--add-dir", + "/src/cron", + "--add-dir", + "/src/pdf", + ] + ); + assert!(plan.protocol_skills_root.is_none()); + } + + #[test] + fn protocol_mode_exposes_the_skills_root_not_the_plugin_root() { + let plan = plan_skill_delivery(input(SkillDelivery { + mode: SkillDeliveryMode::Protocol, + args: Vec::new(), + allow_dir_args: Vec::new(), + method: Some("skills/extraRoots/set".into()), + })); + assert_eq!( + plan.protocol_skills_root.as_deref(), + Some("/data/session-skills/u/c/skills"), + "extraRoots expects the skills root, not the plugin root" + ); + assert!(plan.extra_args.is_empty()); + } + + /// Allow-listing is orthogonal to mode, so `injected` still emits it. + #[test] + fn injected_mode_still_emits_allow_dir_args_but_not_argv_args() { + let plan = plan_skill_delivery(input(SkillDelivery { + mode: SkillDeliveryMode::Injected, + args: vec!["--plugin-dir".into(), "{skill_view_dir}".into()], + allow_dir_args: vec!["--add-dir".into(), "{skill_dir}".into()], + method: None, + })); + assert_eq!( + plan.extra_args, + vec!["--add-dir", "/src/cron", "--add-dir", "/src/pdf"], + "`args` belongs to argv mode only; allow_dir_args applies to every mode" + ); + } + + /// A leftover placeholder means a newer registry wrote it. Dropping the flag + /// would silently change the spawn, so it is kept verbatim and reported. + #[test] + fn an_unrecognized_placeholder_is_kept_verbatim_and_does_not_clear_the_list() { + let plan = plan_skill_delivery(input(SkillDelivery { + mode: SkillDeliveryMode::Argv, + args: vec!["--flag".into(), "{unknown_placeholder}".into()], + allow_dir_args: Vec::new(), + method: None, + })); + assert_eq!(plan.extra_args, vec!["--flag", "{unknown_placeholder}"]); + assert_eq!(plan.unknown_placeholders, vec!["{unknown_placeholder}"]); + } + + #[test] + fn no_skills_means_no_delivery_at_all() { + let plan = plan_skill_delivery(SkillDeliveryPlanInput { + delivery: Some(SkillDelivery { + mode: SkillDeliveryMode::Argv, + args: vec!["--plugin-dir".into(), "{skill_view_dir}".into()], + allow_dir_args: vec!["--add-dir".into(), "{skill_dir}".into()], + method: None, + }), + view_dir: Some("/data/session-skills/u/c".into()), + view_skills_dir: Some("/data/session-skills/u/c/skills".into()), + skill_dirs: Vec::new(), + }); + assert!( + plan.extra_args.is_empty(), + "an empty snapshot must not register an empty plugin root, which would \ + still cost an always-on token line" + ); + assert!(plan.protocol_skills_root.is_none()); + } + + #[test] + fn a_null_column_plans_as_injected_with_nothing_to_add() { + let plan = plan_skill_delivery(SkillDeliveryPlanInput { + delivery: None, + view_dir: Some("/data/session-skills/u/c".into()), + view_skills_dir: Some("/data/session-skills/u/c/skills".into()), + skill_dirs: dirs(), + }); + assert_eq!(plan.mode, SkillDeliveryMode::Injected); + assert!(plan.extra_args.is_empty()); + } + + /// The view path is unavailable when the id failed validation. An argv-mode + /// vendor must then contribute no plugin flag rather than a half-substituted + /// one that would point the CLI at a literal `{skill_view_dir}` directory. + #[test] + fn a_missing_view_dir_drops_the_argv_args_but_keeps_allow_listing() { + let plan = plan_skill_delivery(SkillDeliveryPlanInput { + delivery: Some(SkillDelivery { + mode: SkillDeliveryMode::Argv, + args: vec!["--plugin-dir".into(), "{skill_view_dir}".into()], + allow_dir_args: vec!["--add-dir".into(), "{skill_dir}".into()], + method: None, + }), + view_dir: None, + view_skills_dir: None, + skill_dirs: dirs(), + }); + assert_eq!( + plan.extra_args, + vec!["--add-dir", "/src/cron", "--add-dir", "/src/pdf"], + "allow-listing does not depend on the view directory" + ); + assert!(plan.protocol_skills_root.is_none()); + } +} diff --git a/crates/aionui-ai-agent/tests/agent_availability_integration.rs b/crates/aionui-ai-agent/tests/agent_availability_integration.rs index 639a5f217..46cd02450 100644 --- a/crates/aionui-ai-agent/tests/agent_availability_integration.rs +++ b/crates/aionui-ai-agent/tests/agent_availability_integration.rs @@ -38,6 +38,7 @@ fn custom_params<'a>( args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -219,6 +220,7 @@ async fn hydrate_refreshes_commandless_managed_builtin_installation() { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, diff --git a/crates/aionui-ai-agent/tests/agent_types_integration.rs b/crates/aionui-ai-agent/tests/agent_types_integration.rs index c6e27ecaa..bc675235e 100644 --- a/crates/aionui-ai-agent/tests/agent_types_integration.rs +++ b/crates/aionui-ai-agent/tests/agent_types_integration.rs @@ -313,7 +313,11 @@ fn build_system_instructions_with_skills_index_appends_index() { assert!(result.contains("## Available Skills")); assert!(result.contains("- **review**: Code review")); assert!(result.contains("- **debug**: Debugging")); - assert!(result.contains("[LOAD_SKILL: skill-name]")); + // The placeholder wording changed from `skill-name` to `` when the + // block gained a second channel. Assert the protocol MARKER plus the new + // command channel: that is the meaningful part of the original check. + assert!(result.contains("[LOAD_SKILL:")); + assert!(result.contains("skills show")); } // --------------------------------------------------------------------------- diff --git a/crates/aionui-ai-agent/tests/skill_manager_integration.rs b/crates/aionui-ai-agent/tests/skill_manager_integration.rs index 36f0d8067..8d8a4d9d1 100644 --- a/crates/aionui-ai-agent/tests/skill_manager_integration.rs +++ b/crates/aionui-ai-agent/tests/skill_manager_integration.rs @@ -306,7 +306,15 @@ fn build_index_text_contains_load_protocol() { ]; let text = build_skills_index_text(&skills); - assert!(text.contains("[LOAD_SKILL: skill-name]")); + // The placeholder wording changed from `skill-name` to `` when the + // block gained its second channel. Asserting the PROTOCOL MARKER keeps the + // meaningful check -- that the fallback protocol is still advertised -- and + // the new assertion below pins the channel the block now prefers. + assert!(text.contains("[LOAD_SKILL:")); + assert!( + text.contains("skills show"), + "the command channel must be advertised alongside the protocol: {text}" + ); assert!(text.contains("- **security**: Security review")); assert!(text.contains("- **tdd**: Test-driven development")); } diff --git a/crates/aionui-api-types/src/agent_discovery.rs b/crates/aionui-api-types/src/agent_discovery.rs index a3e4b8913..83b37516a 100644 --- a/crates/aionui-api-types/src/agent_discovery.rs +++ b/crates/aionui-api-types/src/agent_discovery.rs @@ -11,6 +11,7 @@ //! depend on the ACP protocol SDK — the ai-agent crate typed-decodes //! them when it needs to. +use crate::skill_delivery::SkillDelivery; use aionui_common::{AgentType, TimestampMs}; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -191,6 +192,14 @@ pub struct AgentMetadata { pub env: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub native_skills_dirs: Option>, + /// How this vendor receives the conversation's skills. `None` behaves as + /// `injected` (the safe default), so an unprobed vendor is zero-intrusion. + /// + /// This — not `native_skills_dirs` — is the delivery signal. The old field + /// conflated "declares a workspace skills dir" with "can discover skills + /// natively" and is now historical/display data only. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skill_delivery: Option, #[serde(default)] pub behavior_policy: BehaviorPolicy, @@ -279,6 +288,8 @@ pub struct AgentManagementRow { pub env: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub native_skills_dirs: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub skill_delivery: Option, #[serde(default)] pub behavior_policy: BehaviorPolicy, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -361,6 +372,7 @@ mod tests { args: vec![], env: vec![], native_skills_dirs: None, + skill_delivery: None, behavior_policy: BehaviorPolicy::default(), yolo_id: None, sort_order: 3100, diff --git a/crates/aionui-api-types/src/custom_agent.rs b/crates/aionui-api-types/src/custom_agent.rs index 4732cac43..b410cbc26 100644 --- a/crates/aionui-api-types/src/custom_agent.rs +++ b/crates/aionui-api-types/src/custom_agent.rs @@ -93,6 +93,30 @@ mod tests { assert!(roundtrip.get("another").is_none()); } + /// `skill_delivery` must NOT be settable through the custom-agent overrides. + /// + /// The AionUi editor parses this struct with a per-field WHITELIST, so adding + /// a field here without the matching frontend change means a user can type it + /// in and have it silently discarded — worse than it being unsupported, + /// because there is no feedback. It is also a vendor capability declaration + /// that belongs to the registry/probe, not a user preference: a custom agent + /// with no declaration falls to `injected`, which works. + /// + /// Opening this up must be a single change that touches both repositories. + #[test] + fn skill_delivery_is_not_a_custom_agent_override() { + let payload = json!({ + "yolo_id": "bypassPermissions", + "skill_delivery": { "mode": "argv", "args": ["--plugin-dir", "/tmp/x"] } + }); + let parsed: CustomAgentAdvancedOverrides = serde_json::from_value(payload).unwrap(); + let roundtrip = serde_json::to_value(&parsed).unwrap(); + assert!( + roundtrip.get("skill_delivery").is_none(), + "skill_delivery must not round-trip through the custom-agent overrides: {roundtrip}" + ); + } + #[test] fn upsert_request_minimal_payload() { let payload = json!({ diff --git a/crates/aionui-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index 8f2c9c0b7..73b235b51 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -31,6 +31,8 @@ mod session_tools; mod shell; mod sidebar; mod skill; +mod skill_delivery; +mod skill_runtime; mod system; mod team; mod team_mcp; @@ -180,6 +182,12 @@ pub use skill::{ SkillImportRecordResponse, SkillListItemResponse, SkillPathsResponse, SkillSourceResponse, WriteAssistantRuleRequest, }; +pub use skill_delivery::{SkillDelivery, SkillDeliveryMode, SkillDeliveryParse, parse_skill_delivery}; +pub use skill_runtime::{ + RuntimeSkillFileQuery, RuntimeSkillFileResponse, RuntimeSkillListItem, RuntimeSkillListResponse, + RuntimeSkillShowResponse, SKILL_RUNTIME_SCHEMA_VERSION, SkillRuntimeEnvelope, SkillRuntimeErrorCode, + SkillRuntimeErrorPayload, SkillRuntimeMeta, +}; pub use system::{ ClientPreferencesResponse, CurrentUserResponse, FeedbackDiagnosticsContextResponse, FeedbackDiagnosticsPrivacyResponse, FeedbackDiagnosticsProfileResponse, FeedbackDiagnosticsQuery, diff --git a/crates/aionui-api-types/src/skill_delivery.rs b/crates/aionui-api-types/src/skill_delivery.rs new file mode 100644 index 000000000..35c0a3d17 --- /dev/null +++ b/crates/aionui-api-types/src/skill_delivery.rs @@ -0,0 +1,182 @@ +//! Per-vendor skill delivery declaration (`agent_metadata.skill_delivery`). +//! +//! This column is an OPEN extension point, not a closed state machine: shipping +//! a new vendor capability must be a data change, never a migration + release. +//! That is why the DB carries no CHECK constraint and why parsing here is +//! deliberately tolerant — an unknown `mode` means "a newer registry wrote +//! this", which is expected and must degrade to `injected`, NOT fail the row. + +use serde::{Deserialize, Serialize}; + +/// How a vendor receives the conversation's skills. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SkillDeliveryMode { + /// Launch-argument delivery (e.g. claude / codebuddy `--plugin-dir`). + Argv, + /// Protocol-request delivery (e.g. codex `skills/extraRoots/set`). + Protocol, + /// Prompt injection + dual channel. The safe default. + Injected, +} + +/// Raw shape as stored. `mode` stays a `String` here so an unrecognized value +/// cannot fail the whole deserialization. +#[derive(Debug, Clone, Deserialize)] +struct RawSkillDelivery { + #[serde(default)] + mode: Option, + #[serde(default)] + args: Vec, + #[serde(default)] + allow_dir_args: Vec, + #[serde(default)] + method: Option, +} + +/// Decoded delivery config. `mode` is always one of the three known values — +/// an unknown input is reported separately by [`SkillDeliveryParse`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SkillDelivery { + pub mode: SkillDeliveryMode, + #[serde(default)] + pub args: Vec, + /// Cross-cutting A: directories to hand the CLI as readable. Independent of + /// `mode` — spec §10.2 #9 proved `--plugin-dir` does NOT exempt a skill + /// directory from the CLI's file-permission check, so layer 1 needs it too. + #[serde(default)] + pub allow_dir_args: Vec, + #[serde(default)] + pub method: Option, +} + +impl SkillDelivery { + /// The safe default: no vendor capability claimed, dual-channel covers it. + pub fn injected_default() -> Self { + Self { + mode: SkillDeliveryMode::Injected, + args: Vec::new(), + allow_dir_args: Vec::new(), + method: None, + } + } +} + +/// Why three branches and not `Result`: "a newer registry wrote a mode we don't +/// know" and "this JSON is corrupt" need DIFFERENT log lines. Collapsing them +/// into one "parse failed" makes registry rollout problems undiagnosable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SkillDeliveryParse { + Ok(SkillDelivery), + UnknownMode { raw_mode: String, delivery: SkillDelivery }, + Malformed { error: String }, +} + +pub fn parse_skill_delivery(raw: Option<&str>) -> SkillDeliveryParse { + let Some(raw) = raw.map(str::trim).filter(|value| !value.is_empty()) else { + return SkillDeliveryParse::Ok(SkillDelivery::injected_default()); + }; + let parsed: RawSkillDelivery = match serde_json::from_str(raw) { + Ok(value) => value, + Err(error) => { + return SkillDeliveryParse::Malformed { + error: error.to_string(), + }; + } + }; + + let mode_text = parsed.mode.as_deref().map(str::trim).unwrap_or(""); + let mode = match mode_text { + "" | "injected" => Some(SkillDeliveryMode::Injected), + "argv" => Some(SkillDeliveryMode::Argv), + "protocol" => Some(SkillDeliveryMode::Protocol), + _ => None, + }; + + let delivery = SkillDelivery { + // Unknown mode degrades to injected while KEEPING args/allow_dir_args: + // `allow_dir_args` is orthogonal to mode and still correct. + mode: mode.clone().unwrap_or(SkillDeliveryMode::Injected), + args: parsed.args, + allow_dir_args: parsed.allow_dir_args, + method: parsed.method, + }; + match mode { + Some(_) => SkillDeliveryParse::Ok(delivery), + None => SkillDeliveryParse::UnknownMode { + raw_mode: mode_text.to_owned(), + delivery, + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn null_falls_back_to_injected() { + let parsed = parse_skill_delivery(None); + let SkillDeliveryParse::Ok(delivery) = parsed else { + panic!("NULL must parse as a plain injected default"); + }; + assert_eq!(delivery.mode, SkillDeliveryMode::Injected); + assert!(delivery.args.is_empty()); + assert!(delivery.allow_dir_args.is_empty()); + } + + #[test] + fn argv_mode_keeps_args_and_allow_dir_args() { + let raw = r#"{"mode":"argv","args":["--plugin-dir","{skill_view_dir}"], + "allow_dir_args":["--add-dir","{skill_dir}"]}"#; + let SkillDeliveryParse::Ok(delivery) = parse_skill_delivery(Some(raw)) else { + panic!("a well-formed argv config must parse cleanly"); + }; + assert_eq!(delivery.mode, SkillDeliveryMode::Argv); + assert_eq!(delivery.args, vec!["--plugin-dir", "{skill_view_dir}"]); + assert_eq!(delivery.allow_dir_args, vec!["--add-dir", "{skill_dir}"]); + } + + #[test] + fn protocol_mode_keeps_method() { + let raw = r#"{"mode":"protocol","method":"skills/extraRoots/set"}"#; + let SkillDeliveryParse::Ok(delivery) = parse_skill_delivery(Some(raw)) else { + panic!("a well-formed protocol config must parse cleanly"); + }; + assert_eq!(delivery.mode, SkillDeliveryMode::Protocol); + assert_eq!(delivery.method.as_deref(), Some("skills/extraRoots/set")); + } + + /// A registry newer than this binary is EXPECTED, not corruption: the whole + /// point of the column is shipping new vendor capabilities as data. + #[test] + fn an_unknown_mode_reports_the_actual_value_and_falls_back() { + let raw = r#"{"mode":"future_mode_v9","allow_dir_args":["--add-dir","{skill_dir}"]}"#; + let SkillDeliveryParse::UnknownMode { raw_mode, delivery } = parse_skill_delivery(Some(raw)) else { + panic!("an unknown mode must be its own branch, not Malformed and not Ok"); + }; + assert_eq!(raw_mode, "future_mode_v9"); + assert_eq!(delivery.mode, SkillDeliveryMode::Injected, "fall back, never fail"); + assert_eq!( + delivery.allow_dir_args, + vec!["--add-dir", "{skill_dir}"], + "allow_dir_args is independent of mode and must survive the fallback" + ); + } + + #[test] + fn malformed_json_is_a_distinct_branch_from_an_unknown_mode() { + let SkillDeliveryParse::Malformed { error } = parse_skill_delivery(Some("{not json")) else { + panic!("corrupt data must be distinguishable from a newer registry"); + }; + assert!(!error.is_empty(), "the error text is what makes it diagnosable"); + } + + #[test] + fn a_missing_mode_key_is_injected_not_malformed() { + let SkillDeliveryParse::Ok(delivery) = parse_skill_delivery(Some(r#"{"args":[]}"#)) else { + panic!("an absent mode is the safe default, not an error"); + }; + assert_eq!(delivery.mode, SkillDeliveryMode::Injected); + } +} diff --git a/crates/aionui-api-types/src/skill_runtime.rs b/crates/aionui-api-types/src/skill_runtime.rs new file mode 100644 index 000000000..85febe73d --- /dev/null +++ b/crates/aionui-api-types/src/skill_runtime.rs @@ -0,0 +1,142 @@ +//! Wire types for the agent-facing `aioncore skills` domain (channel A). +//! +//! Deliberately separate from `skill.rs`, which is the read-WRITE management +//! surface listing every importable skill. This domain is read-only and scoped +//! to ONE conversation's `extra.skills` snapshot: different semantics, different +//! authority, and merging them would let a runtime token reach the management +//! surface. +//! +//! The envelope mirrors the `session` CLI's shape (success flag + data + error + +//! meta) because the agent already knows how to read that, but it carries its +//! own error codes rather than borrowing the session domain's. + +use serde::{Deserialize, Serialize}; + +pub const SKILL_RUNTIME_SCHEMA_VERSION: u32 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SkillRuntimeErrorCode { + /// The runtime token was missing, malformed, or not valid for this + /// (user, conversation) pair. + RuntimeAuthFailed, + /// The conversation does not exist for this user. + ConversationNotFound, + /// The skill exists somewhere, but is not enabled in THIS conversation. A + /// distinct code from `skill_not_found` on purpose: the agent should stop + /// asking rather than retry, and an operator reading logs should be able to + /// tell a snapshot mismatch from a missing file. + SkillNotEnabled, + /// Enabled in the snapshot, but no source directory resolves for this user. + SkillNotFound, + /// The requested relative path escaped the skill directory, or was absolute. + InvalidPath, + /// Malformed request (missing/unknown stdin field, unreadable file). + SchemaValidationFailed, + /// The CLI could not reach the backend at all. + TransportUnavailable, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillRuntimeErrorPayload { + pub code: SkillRuntimeErrorCode, + pub message: String, +} + +impl SkillRuntimeErrorPayload { + pub fn new(code: SkillRuntimeErrorCode, message: impl Into) -> Self { + Self { + code, + message: message.into(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillRuntimeMeta { + pub schema_version: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub command: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SkillRuntimeEnvelope { + pub success: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + pub meta: SkillRuntimeMeta, +} + +impl SkillRuntimeEnvelope { + pub fn success(data: T, command: Option) -> Self { + Self { + success: true, + data: Some(data), + error: None, + meta: SkillRuntimeMeta { + schema_version: SKILL_RUNTIME_SCHEMA_VERSION, + command, + }, + } + } + + pub fn failure(error: SkillRuntimeErrorPayload, command: Option) -> Self { + Self { + success: false, + data: None, + error: Some(error), + meta: SkillRuntimeMeta { + schema_version: SKILL_RUNTIME_SCHEMA_VERSION, + command, + }, + } + } +} + +/// One entry of `skills list`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeSkillListItem { + /// The BARE snapshot name (`cron`). Note this is not necessarily what the + /// agent's own CLI calls the skill: under plugin-based delivery it sees a + /// prefixed name (`aionui:cron`). + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeSkillListResponse { + pub skills: Vec, +} + +/// `skills show `: the full body PLUS the absolute skill root. +/// +/// Both, not either: a read-only agent needs the content, while one that can run +/// commands needs the path so it can reach `references/` and `scripts/`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeSkillShowResponse { + pub name: String, + /// Frontmatter stripped, byte-identical to what the `[LOAD_SKILL]` channel + /// injects (both go through `aionui_extension::extract_skill_body`). + pub body: String, + /// Absolute skill directory. Every relative reference inside `body` resolves + /// against this, NOT against the agent's working directory. + pub path: String, +} + +/// `skills cat /`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeSkillFileResponse { + pub name: String, + /// The relative path as requested, echoed back for correlation. + pub path: String, + pub content: String, +} + +/// Query for the file read. A separate `path` parameter rather than extra route +/// segments so a nested `references/sub/x.md` needs no escaping games. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeSkillFileQuery { + pub path: String, +} diff --git a/crates/aionui-app/Cargo.toml b/crates/aionui-app/Cargo.toml index 9231e2b70..f0fa6126e 100644 --- a/crates/aionui-app/Cargo.toml +++ b/crates/aionui-app/Cargo.toml @@ -31,6 +31,7 @@ aionui-shell.workspace = true aionui-ai-agent.workspace = true aionui-session.workspace = true aionui-session-message.workspace = true +aionui-skill-runtime.workspace = true aionui-mcp.workspace = true aionui-conversation.workspace = true aionui-extension.workspace = true diff --git a/crates/aionui-app/src/cli.rs b/crates/aionui-app/src/cli.rs index ccc891355..ea39878b0 100644 --- a/crates/aionui-app/src/cli.rs +++ b/crates/aionui-app/src/cli.rs @@ -117,6 +117,10 @@ pub(crate) enum Command { /// Cross-session messaging: list deliverable conversations and deliver a /// message to one of them. Session(SessionArgs), + /// Agent-facing read-only runtime CLI for THIS conversation's skills. + /// Channel A of skill delivery: a normal tool call instead of the + /// `[LOAD_SKILL]` text-protocol round trip. + Skills(SkillsArgs), /// PreToolUse permission gate for the Antigravity CLI (spawned by agy). /// Reads the tool request on stdin, asks the running AionUi backend, and /// writes agy's decision to stdout. @@ -156,6 +160,7 @@ impl Command { Self::Diagnose(_) => "diagnose", Self::Team(_) => "team", Self::Session(_) => "session", + Self::Skills(_) => "skills", Self::AntigravityHook => "antigravity-hook", Self::McpTeamStdio => "mcp-team-stdio", Self::Doctor => "doctor", @@ -239,6 +244,44 @@ pub(crate) enum SessionCommand { Unknown(Vec), } +#[derive(Args, Debug, Clone)] +pub(crate) struct SkillsArgs { + #[command(subcommand)] + pub command: SkillsCommand, +} + +#[derive(Subcommand, Debug, Clone)] +pub(crate) enum SkillsCommand { + /// Print the agent-readable skills CLI capability contract. + Capabilities, + /// List the skills enabled in THIS conversation. + List, + // The stdin contract is spelled out in the help text below because omitting it + // has a measured cost: live agents that tried `skills show ` and then + // reached for `--help` found nothing about stdin, and spent three to five + // failed tool calls guessing. Help text stays purely instructional -- this + // rationale is for maintainers, so it does not belong on the page an agent + // reads. + /// Print a skill's full body plus its absolute directory. + /// + /// Takes no positional arguments. The skill name is read from stdin as a JSON + /// object: + /// + /// {n} printf '%s' '{"name":"mermaid"}' | aioncore skills show + #[command(verbatim_doc_comment)] + Show, + /// Read one of a skill's supplementary files. + /// + /// Takes no positional arguments. The path is read from stdin as a JSON + /// object, and must be `/`: + /// + /// {n} printf '%s' '{"path":"mermaid/references/syntax.md"}' | aioncore skills cat + #[command(verbatim_doc_comment)] + Cat, + #[command(external_subcommand)] + Unknown(Vec), +} + #[derive(Subcommand, Debug, Clone)] pub(crate) enum DiagnoseCommand { /// Print the agent-readable diagnose CLI capability contract. @@ -1264,4 +1307,59 @@ mod tests { other => panic!("unexpected command parsed: {other:?}"), } } + + /// Every `aioncore skills …` command line the injected skills index teaches an + /// agent must actually parse. + /// + /// This pins two crates together. The index text lives in `aionui-ai-agent` + /// and the argument grammar lives here, so nothing structural stopped them + /// from disagreeing -- and they did: the index taught `skills show ` + /// while `Show` accepts no positional arguments at all. Unit tests on either + /// side stayed green (the text side only asserted the string mentioned + /// `$AIONUI_HELPER_BIN`), and the mismatch only surfaced against live agents, + /// which spent three to five failed tool calls each recovering from it. + #[test] + fn skills_cli_commands_in_the_index_are_parseable() { + let index = aionui_ai_agent::build_skills_index_text(&[aionui_ai_agent::SkillIndex { + name: "mermaid".to_owned(), + description: "Render Mermaid diagrams.".to_owned(), + }]); + + // Pull out each taught invocation: the segment that starts at the helper + // binary placeholder and runs to the end of its backticked command. + let mut checked = 0usize; + for segment in index.split('`') { + let Some((_, after_bin)) = segment.split_once("\"$AIONUI_HELPER_BIN\"") else { + continue; + }; + let argv: Vec<&str> = after_bin.split_whitespace().collect(); + if argv.first() != Some(&"skills") { + continue; + } + + let parsed = Cli::try_parse_from(std::iter::once("aioncore").chain(argv.iter().copied())); + assert!( + parsed.is_ok(), + "the skills index teaches `aioncore {}`, which the CLI rejects: {}", + argv.join(" "), + parsed.err().map(|e| e.to_string()).unwrap_or_default() + ); + checked += 1; + } + + assert!( + checked >= 3, + "expected the index to teach `skills show`, `skills cat` and `skills capabilities`; \ + only {checked} parseable invocation(s) were found -- if the wording changed, update \ + this extraction rather than dropping the guard" + ); + + // The failure mode this test exists for, stated directly: a positional + // argument after `show` or `cat` is not a wording preference, it is a + // command the binary refuses. + assert!( + Cli::try_parse_from(["aioncore", "skills", "show", "mermaid"]).is_err(), + "`skills show ` must stay a parse error, so the index can never teach it again" + ); + } } diff --git a/crates/aionui-app/src/commands/cmd_capabilities.rs b/crates/aionui-app/src/commands/cmd_capabilities.rs index 33b83c2d9..8cf80e767 100644 --- a/crates/aionui-app/src/commands/cmd_capabilities.rs +++ b/crates/aionui-app/src/commands/cmd_capabilities.rs @@ -121,6 +121,21 @@ fn data() -> Value { "does_not_accept_identity_authority_from_stdin": true, "per_user_feature_switch": "list and send-message answer feature_disabled while the user has cross-session messaging switched off; capabilities stays available because it reads no conversation data" } + }, + { + "name": "skills", + "mode": "read-only", + "description": "Read the skills enabled in THIS conversation: list them, get a skill's full body plus its absolute directory, and read its supplementary files.", + "contract": "agent-facing-skills-cli", + "contract_command": "skills capabilities", + "invocation": "aioncore skills capabilities", + "runtime_required": ["AIONUI_BASE_URL", "AIONUI_CONVERSATION_ID", "AIONUI_USER_ID", "AIONUI_RUNTIME_TOKEN"], + "runtime_free_commands": ["skills capabilities"], + "safety": { + "can_write": false, + "read_only": true, + "scoped_to_conversation_snapshot": true + } } ], "non_agent_subcommands": [ @@ -174,7 +189,9 @@ mod tests { use super::*; use crate::cli::Cli; - use crate::commands::{config_capabilities, diagnose_capabilities, session_capabilities, team_capabilities}; + use crate::commands::{ + config_capabilities, diagnose_capabilities, session_capabilities, skills_capabilities, team_capabilities, + }; /// `capabilities` is its own entrypoint — `data()` declares it under /// `entrypoint`, not as one of the domains it indexes. @@ -255,6 +272,7 @@ mod tests { ("diagnose", diagnose_capabilities::data()), ("team", team_capabilities::data()), ("session", session_capabilities::data()), + ("skills", skills_capabilities::data()), ] { let entry = domains .iter() @@ -277,4 +295,21 @@ mod tests { ); } } + + /// Not covered by the structural invariants above: a read-only domain must + /// not advertise write authority. An agent reads `safety.can_write` to decide + /// whether a command is safe to attempt at all. + #[test] + fn the_skills_domain_is_declared_read_only() { + let skills = data()["domains"] + .as_array() + .unwrap() + .iter() + .find(|domain| domain["name"] == "skills") + .expect("skills domain") + .clone(); + assert_eq!(skills["mode"], "read-only"); + assert_eq!(skills["safety"]["can_write"], false); + assert_eq!(skills["safety"]["scoped_to_conversation_snapshot"], true); + } } diff --git a/crates/aionui-app/src/commands/cmd_skills.rs b/crates/aionui-app/src/commands/cmd_skills.rs new file mode 100644 index 000000000..62d4c4db6 --- /dev/null +++ b/crates/aionui-app/src/commands/cmd_skills.rs @@ -0,0 +1,324 @@ +//! `aioncore skills` — the agent-facing runtime skills CLI (channel A). +//! +//! Shaped after `cmd_session.rs`: the same three runtime headers, the same +//! envelope-on-every-failure discipline, the same "a malformed env var must not +//! panic" handling. Differences: every subcommand is a GET (this domain is +//! read-only), and `cat` splits its stdin `path` into a skill name plus a +//! relative path. + +use std::ffi::OsString; +use std::io::{self, Read, Write}; +use std::process::ExitCode; + +use aionui_api_types::{SkillRuntimeEnvelope, SkillRuntimeErrorCode, SkillRuntimeErrorPayload}; +use serde_json::{Value, json}; + +use crate::cli::{SkillsArgs, SkillsCommand}; +use crate::commands::skills_capabilities; + +const ENV_BASE_URL: &str = "AIONUI_BASE_URL"; +const ENV_USER_ID: &str = "AIONUI_USER_ID"; +const ENV_CONVERSATION_ID: &str = "AIONUI_CONVERSATION_ID"; +const ENV_RUNTIME_TOKEN: &str = "AIONUI_RUNTIME_TOKEN"; + +pub(crate) async fn run_skills(args: SkillsArgs) -> ExitCode { + match run_skills_inner(args).await { + Ok(()) => ExitCode::SUCCESS, + Err(code) => code, + } +} + +async fn run_skills_inner(args: SkillsArgs) -> Result<(), ExitCode> { + match args.command { + // Static contract: no conversation data, so it works with no runtime env. + SkillsCommand::Capabilities => print_json(&SkillRuntimeEnvelope::success( + skills_capabilities::data(), + Some("skills capabilities".to_owned()), + )), + SkillsCommand::List => list().await, + SkillsCommand::Show => show().await, + SkillsCommand::Cat => cat().await, + SkillsCommand::Unknown(path) => Err(unknown_command(path)), + } +} + +async fn list() -> Result<(), ExitCode> { + let command = "skills list"; + let env = runtime_env(command)?; + let url = format!("{}/api/runtime/skills", env.base_url.trim_end_matches('/')); + get(command, &env, url).await +} + +async fn show() -> Result<(), ExitCode> { + let command = "skills show"; + let env = runtime_env(command)?; + let name = required_string_field(command, "name")?; + let url = format!( + "{}/api/runtime/skills/{}", + env.base_url.trim_end_matches('/'), + urlencode(&name) + ); + get(command, &env, url).await +} + +async fn cat() -> Result<(), ExitCode> { + let command = "skills cat"; + let env = runtime_env(command)?; + let path = required_string_field(command, "path")?; + let (name, rel) = split_skill_path(command, &path)?; + let url = format!( + "{}/api/runtime/skills/{}/file?path={}", + env.base_url.trim_end_matches('/'), + urlencode(&name), + urlencode(&rel) + ); + get(command, &env, url).await +} + +/// Split `/` at the FIRST separator, so a nested +/// `references/sub/x.md` keeps its own slashes. +fn split_skill_path(command: &str, path: &str) -> Result<(String, String), ExitCode> { + let trimmed = path.trim(); + match trimmed.split_once('/') { + Some((name, rel)) if !name.is_empty() && !rel.is_empty() => Ok((name.to_owned(), rel.to_owned())), + _ => Err(print_failure( + command, + "SKILLS_CLI_SCHEMA_VALIDATION_FAILED", + SkillRuntimeErrorPayload::new( + SkillRuntimeErrorCode::SchemaValidationFailed, + "`path` must be of the form /", + ), + )), + } +} + +async fn get(command: &str, env: &RuntimeEnv, url: String) -> Result<(), ExitCode> { + let response = reqwest::Client::new() + .get(url) + .headers(env.headers(command)?) + .send() + .await + .map_err(|error| runtime_error(command, "SKILLS_CLI_HTTP_BRIDGE_FAILED", error.to_string()))?; + print_response(command, response).await +} + +struct RuntimeEnv { + base_url: String, + user_id: String, + conversation_id: String, + runtime_token: String, +} + +impl RuntimeEnv { + /// Fallible on purpose: a header value rejects control characters, so a + /// malformed `AIONUI_*` variable would otherwise panic — and a panicking CLI + /// prints no envelope at all, leaving the agent with a stack trace instead of + /// something it can report. + fn headers(&self, command: &str) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + for (name, value) in [ + ("x-aionui-user-id", &self.user_id), + ("x-aionui-conversation-id", &self.conversation_id), + ("x-aionui-runtime-token", &self.runtime_token), + ] { + let parsed = value.parse().map_err(|_| { + // The NAME only — a token must never reach stdout or stderr. + runtime_error( + command, + "SKILLS_CLI_HEADER_INVALID", + format!("environment variable for {name} is not a valid header value"), + ) + })?; + headers.insert(name, parsed); + } + Ok(headers) + } +} + +fn runtime_env(command: &str) -> Result { + Ok(RuntimeEnv { + base_url: required_env(command, ENV_BASE_URL)?, + user_id: required_env(command, ENV_USER_ID)?, + conversation_id: required_env(command, ENV_CONVERSATION_ID)?, + runtime_token: required_env(command, ENV_RUNTIME_TOKEN)?, + }) +} + +fn required_env(command: &str, name: &'static str) -> Result { + std::env::var(name).map_err(|_| { + print_failure( + command, + "SKILLS_CLI_ENV_MISSING", + SkillRuntimeErrorPayload::new( + SkillRuntimeErrorCode::TransportUnavailable, + format!("missing required environment variable: {name}"), + ), + ) + }) +} + +fn read_stdin_json_object(command: &str) -> Result { + let mut input = String::new(); + io::stdin().read_to_string(&mut input).map_err(|error| { + print_failure( + command, + "SKILLS_CLI_STDIN_READ_FAILED", + SkillRuntimeErrorPayload::new(SkillRuntimeErrorCode::SchemaValidationFailed, error.to_string()), + ) + })?; + if input.trim().is_empty() { + return Ok(json!({})); + } + serde_json::from_str(&input).map_err(|error| { + print_failure( + command, + "SKILLS_CLI_STDIN_JSON_INVALID", + SkillRuntimeErrorPayload::new(SkillRuntimeErrorCode::SchemaValidationFailed, error.to_string()), + ) + }) +} + +fn required_string_field(command: &str, field: &'static str) -> Result { + let value = read_stdin_json_object(command)?; + value + .get(field) + .and_then(Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + print_failure( + command, + "SKILLS_CLI_SCHEMA_VALIDATION_FAILED", + SkillRuntimeErrorPayload::new( + SkillRuntimeErrorCode::SchemaValidationFailed, + format!("missing required stdin field: {field}"), + ), + ) + }) +} + +/// Minimal percent-encoding. Deliberately not a new dependency: the only inputs +/// are a skill name and a relative path. +fn urlencode(value: &str) -> String { + let mut encoded = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => encoded.push(byte as char), + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + +async fn print_response(command: &str, response: reqwest::Response) -> Result<(), ExitCode> { + let status = response.status(); + let text = response + .text() + .await + .map_err(|error| runtime_error(command, "SKILLS_CLI_HTTP_RESPONSE_FAILED", error.to_string()))?; + if !status.is_success() { + eprintln!( + "SKILLS_CLI_HTTP_STATUS_ERROR command={command} status={status}: runtime bridge returned non-success status" + ); + // The body is already an envelope carrying a stable error code, so print + // it verbatim rather than re-wrapping and losing the code. + println!("{text}"); + return Err(ExitCode::from(3)); + } + println!("{text}"); + Ok(()) +} + +fn runtime_error(command: &str, code: &'static str, message: String) -> ExitCode { + print_failure( + command, + code, + SkillRuntimeErrorPayload::new(SkillRuntimeErrorCode::TransportUnavailable, message), + ) +} + +fn unknown_command(path: Vec) -> ExitCode { + let suffix = path + .into_iter() + .map(|part| part.to_string_lossy().into_owned()) + .collect::>() + .join(" "); + let command = if suffix.is_empty() { + "skills".to_owned() + } else { + format!("skills {suffix}") + }; + print_failure( + &command, + "SKILLS_CLI_UNKNOWN_COMMAND", + SkillRuntimeErrorPayload::new( + SkillRuntimeErrorCode::SchemaValidationFailed, + "unknown skills command; run `skills capabilities` for the contract", + ), + ) +} + +fn print_failure(command: &str, stderr_code: &'static str, error: SkillRuntimeErrorPayload) -> ExitCode { + eprintln!("{stderr_code} command={command}: {}", error.message); + let _ = print_json(&SkillRuntimeEnvelope::::failure(error, Some(command.to_owned()))); + ExitCode::from(2) +} + +fn print_json(value: &T) -> Result<(), ExitCode> { + let rendered = serde_json::to_string_pretty(value).map_err(|_| ExitCode::from(1))?; + let mut stdout = io::stdout(); + stdout + .write_all(rendered.as_bytes()) + .and_then(|_| stdout.write_all(b"\n")) + .map_err(|_| ExitCode::from(1)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_nested_relative_path_keeps_its_own_separators() { + let (name, rel) = split_skill_path("skills cat", "morph-ppt/reference/styles/INDEX.md").unwrap(); + assert_eq!(name, "morph-ppt"); + assert_eq!( + rel, "reference/styles/INDEX.md", + "only the FIRST separator splits, or nested files become unreachable" + ); + } + + #[test] + fn a_path_without_a_relative_part_is_refused_locally() { + // Refused here rather than after a round trip, so a typo comes back as a + // schema error instead of a confusing server-side rejection. + for bad in ["cron", "cron/", "/notes.md", "", " "] { + assert!(split_skill_path("skills cat", bad).is_err(), "{bad:?} must be refused"); + } + } + + /// A traversal attempt must survive encoding intact so the SERVER refuses it. + /// Silently mangling it here would hide the attempt from the server's log. + #[test] + fn traversal_shapes_are_encoded_not_swallowed() { + let (name, rel) = split_skill_path("skills cat", "cron/../../etc/passwd").unwrap(); + assert_eq!(name, "cron"); + assert_eq!(rel, "../../etc/passwd"); + let encoded = urlencode(&rel); + assert!(!encoded.contains('/'), "separators must be escaped: {encoded}"); + // `.` is unreserved (RFC 3986) so `..` stays literal; only the separators + // are escaped. Either way the traversal arrives intact and the SERVER + // refuses it — mangling it here would hide the attempt from its log. + assert!( + encoded.starts_with("..%2F..%2F"), + "the traversal must reach the server intact: {encoded}" + ); + } + + #[test] + fn query_values_are_percent_encoded() { + assert_eq!(urlencode("references/notes.md"), "references%2Fnotes.md"); + assert!(!urlencode("a b&c=1").contains(' ')); + assert!(!urlencode("a b&c=1").contains('&')); + } +} diff --git a/crates/aionui-app/src/commands/mod.rs b/crates/aionui-app/src/commands/mod.rs index ad0262b3d..36b4f8c15 100644 --- a/crates/aionui-app/src/commands/mod.rs +++ b/crates/aionui-app/src/commands/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod cmd_prepare_managed_resources; pub(crate) mod cmd_secret; pub(crate) mod cmd_server; pub(crate) mod cmd_session; +pub(crate) mod cmd_skills; pub(crate) mod cmd_team; pub(crate) mod cmd_team_stdio; pub(crate) mod cmd_user; @@ -19,6 +20,7 @@ pub(crate) mod config_capabilities; pub(crate) mod diagnose_capabilities; pub(crate) mod error; pub(crate) mod session_capabilities; +pub(crate) mod skills_capabilities; pub(crate) mod team_capabilities; pub(crate) use cmd_antigravity_hook::run_antigravity_hook; @@ -30,6 +32,7 @@ pub(crate) use cmd_prepare_managed_resources::run_prepare_managed_resources; pub(crate) use cmd_secret::run_secret; pub(crate) use cmd_server::{bind_http_listener, run_server}; pub(crate) use cmd_session::run_session; +pub(crate) use cmd_skills::run_skills; pub(crate) use cmd_team::run_team; pub(crate) use cmd_team_stdio::run_team_stdio; pub(crate) use cmd_user::run_user; diff --git a/crates/aionui-app/src/commands/skills_capabilities.rs b/crates/aionui-app/src/commands/skills_capabilities.rs new file mode 100644 index 000000000..f3c318701 --- /dev/null +++ b/crates/aionui-app/src/commands/skills_capabilities.rs @@ -0,0 +1,84 @@ +//! Self-describing contract for `aioncore skills`. +//! +//! Static data touching no conversation state, so it works with no runtime env +//! at all — which matters because this is the command an agent runs first to +//! learn the domain exists. + +use serde_json::{Value, json}; + +pub(crate) fn data() -> Value { + json!({ + "schema_version": 1, + "contract": "agent-facing-skills-cli", + "stability": "stable", + "entrypoint": "aioncore skills capabilities", + "purpose": "Read the skills enabled in THIS conversation: list them, get a skill's \ + full body plus its absolute directory, and read its supplementary files.", + "relationship_to_config_skills": "Distinct domain. `config skills *` is the read-write \ + management surface over EVERY importable skill on the installation. This domain is \ + read-only and scoped to this conversation's enabled set; the two are not \ + interchangeable and this one can never write.", + "output": { + "stdout": "JSON envelope", + "stderr": "single stable ..._FAILED error line on failure", + "success_shape": { + "success": true, + "data": {}, + "meta": { "schema_version": 1, "command": "skills list" } + } + }, + "runtime_context": { + "environment": [ + "AIONUI_BASE_URL", + "AIONUI_CONVERSATION_ID", + "AIONUI_USER_ID", + "AIONUI_RUNTIME_TOKEN" + ], + "runtime_free_commands": ["skills capabilities"] + }, + "input": { "default_mode": "stdin_json", "business_flags": false }, + "commands": [ + { + "name": "list", + "invocation": "aioncore skills list", + "stdin": {}, + "returns": { "skills": [{ "name": "string", "description": "string" }] }, + "notes": "Only the skills this conversation enabled. Sorted by name." + }, + { + "name": "show", + "invocation": "aioncore skills show", + "stdin": { "name": "string (required)" }, + "returns": { "name": "string", "body": "string", "path": "absolute directory" }, + "notes": "`body` has the frontmatter stripped and is identical to what the \ + `[LOAD_SKILL: name]` protocol injects. Resolve every relative path \ + inside `body` against `path`, NOT against your working directory." + }, + { + "name": "cat", + "invocation": "aioncore skills cat", + "stdin": { "path": "string (required), of the form /" }, + "returns": { "name": "string", "path": "string", "content": "string" }, + "notes": "Reads a supplementary file such as references/notes.md. Confined to \ + the skill's own directory; `..`, absolute paths, and symlinks leaving \ + the directory are rejected." + } + ], + "errors": { + "skill_not_enabled": "The skill exists but is not enabled in this conversation. Do \ + not retry with variations; use `skills list` to see what is available.", + "skill_not_found": "Enabled here, but no source directory resolves. A broken \ + install rather than a permission problem.", + "invalid_path": "The requested path left the skill directory.", + "runtime_auth_failed": "Missing or wrong runtime token / conversation id." + }, + "safety": { + "can_write": false, + "read_only": true, + "scoped_to_conversation_snapshot": true + }, + "fallback": "If you cannot execute commands at all (plan mode, read-only, scheduled \ + runs), output `[LOAD_SKILL: ]` in your reply instead; the harness loads the \ + body and feeds it back on the next turn." + }) +} diff --git a/crates/aionui-app/src/main.rs b/crates/aionui-app/src/main.rs index 24582ccef..e7d76e2d3 100644 --- a/crates/aionui-app/src/main.rs +++ b/crates/aionui-app/src/main.rs @@ -83,6 +83,7 @@ async fn async_main(merged_path: String, cli: Cli) -> Result Ok(commands::run_diagnose(args).await), Some(Command::Team(args)) => Ok(commands::run_team(args).await), Some(Command::Session(args)) => Ok(commands::run_session(args).await), + Some(Command::Skills(args)) => Ok(commands::run_skills(args).await), Some(Command::AntigravityHook) => Ok(commands::run_antigravity_hook().await), Some(Command::McpTeamStdio) => Ok(commands::run_team_stdio().await), Some(Command::Doctor) => Ok(commands::run_doctor(&cli, &merged_path).await?), diff --git a/crates/aionui-app/src/router/routes.rs b/crates/aionui-app/src/router/routes.rs index 7e81b94fb..c7f5db104 100644 --- a/crates/aionui-app/src/router/routes.rs +++ b/crates/aionui-app/src/router/routes.rs @@ -45,6 +45,7 @@ use crate::services::AppServices; use super::fs_monitor::spawn_fs_monitor; use super::health::health_check; use aionui_session_message::{session_message_routes, session_message_user_routes}; +use aionui_skill_runtime::skill_runtime_routes; use super::runtime_team_tools::{RuntimeTeamToolsState, runtime_team_tools_routes}; use super::scm_monitor::{CompositeMessageRouter, spawn_scm_monitor}; @@ -368,6 +369,9 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates // Runtime routes authenticate on their own token header — deliberately NOT // behind auth_middleware, same as runtime_team_tools. let session_message_runtime = session_message_routes(states.session_message.clone()); + // Channel A. Same runtime-token self-authentication: the caller is an agent + // process holding a conversation-scoped token, not a browser session. + let skill_runtime = skill_runtime_routes(states.skill_runtime); // The `@@` picker's outlet goes through ordinary user auth. let session_message_authenticated = session_message_user_routes(states.session_message) .route_layer(from_fn_with_state(auth_mw_state.clone(), auth_middleware)); @@ -400,6 +404,7 @@ pub fn create_router_with_all_state(services: &AppServices, states: ModuleStates .merge(extension_authenticated) .merge(hub_authenticated) .merge(skill_authenticated) + .merge(skill_runtime) .merge(channel_authenticated) .merge(team_authenticated) .merge(cron_authenticated) diff --git a/crates/aionui-app/src/router/state.rs b/crates/aionui-app/src/router/state.rs index ce821ae2d..55db9eab5 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -42,6 +42,7 @@ use aionui_session_message::state::SessionMessageRouterState; use aionui_session_message::targets::MentionableTargets; use aionui_shell::ShellRouterState; use aionui_sidebar::{ArchiveTeardownPorts, SidebarRouterState, SidebarService}; +use aionui_skill_runtime::{SkillRuntimeRouterState, SkillRuntimeService}; use aionui_system::{ ClientPrefService, ConnectionTestRouterState, ConnectionTestService, FeedbackDiagnosticsService, ModelFetchService, ProtocolDetectionService, ProviderService, RuntimePrepareService, SettingsService, SystemRouterState, @@ -143,6 +144,7 @@ pub struct ModuleStates { pub channel: ChannelRouterState, pub team: TeamRouterState, pub session_message: SessionMessageRouterState, + pub skill_runtime: SkillRuntimeRouterState, pub cron: CronRouterState, pub office: OfficeRouterState, pub shell: ShellRouterState, @@ -327,6 +329,7 @@ pub async fn build_module_states( ) }), session_message: build_module_state_phase(&boot, "session_message", || build_session_message_state(services)), + skill_runtime: build_module_state_phase(&boot, "skill_runtime", || build_skill_runtime_state(services)), cron, office: build_module_state_phase(&boot, "office", || build_office_state(services)), shell: build_module_state_phase(&boot, "shell", || build_shell_state(services)), @@ -372,6 +375,21 @@ pub async fn build_module_states( /// The drainer is spawned here rather than in `routes.rs` because this is where /// this crate already starts background work — see /// `spawn_assistant_mcp_binding_watcher`, called from `build_assistant_state`. +/// Channel A's state. Read-only, so it takes the conversation repo (for the +/// snapshot allow-list), the skill paths + repo (to resolve and read), and the +/// runtime token service (to authenticate). Deliberately NOT the skill WRITE +/// surface's state: this domain can never import, delete, or enable a skill. +pub fn build_skill_runtime_state(services: &AppServices) -> SkillRuntimeRouterState { + SkillRuntimeRouterState { + service: Arc::new(SkillRuntimeService::new( + services.conversation_repo.clone(), + services.skill_paths.clone(), + services.skill_repo.clone(), + )), + runtime_token_service: services.runtime_token_service.clone(), + } +} + pub fn build_session_message_state(services: &AppServices) -> SessionMessageRouterState { let state = SessionMessageRouterState { service: services.session_message_service.clone(), diff --git a/crates/aionui-app/src/services.rs b/crates/aionui-app/src/services.rs index f660d1f14..f5a4c6b37 100644 --- a/crates/aionui-app/src/services.rs +++ b/crates/aionui-app/src/services.rs @@ -310,6 +310,33 @@ impl AppServices { .map_err(|e| anyhow::anyhow!("Failed to synchronize skill catalog: {e}"))?; } + // Reap per-conversation skill view directories whose conversation is gone. + // + // A view outlives its conversation only when the delete hook did not run + // (crash, forced kill), so this is a startup sweep rather than a timer. + // Keyed by the (user, conversation) PAIR: two Core users can hold + // same-shaped conversation ids, and reaping by id alone would delete one + // user's view because the other's conversation was deleted. + match conversation_repo.list_all_conversation_ids().await { + Ok(live) => { + let live: std::collections::HashSet<(String, String)> = live.into_iter().collect(); + match aionui_extension::skill_view::cleanup_orphan_views(&data_dir, &live).await { + Ok(removed) if removed > 0 => { + tracing::info!(removed, "startup: reaped orphan skill view directories"); + } + Ok(_) => {} + Err(error) => { + tracing::warn!(error = %error, "startup: orphan skill view cleanup failed"); + } + } + } + // Reaping nothing is the safe direction: a leaked view costs disk, + // a wrongly-deleted one costs a session its skills. + Err(error) => { + tracing::warn!(error = %error, "startup: could not list conversations; skipped skill view cleanup"); + } + } + // Absolute path to this process's binary. Reused as the `command` for // the stdio MCP bridge spawned by ACP CLIs when a team session is // attached to a conversation (phase1 mcp.md §4.6 single-binary model). diff --git a/crates/aionui-app/tests/acp_e2e.rs b/crates/aionui-app/tests/acp_e2e.rs index 07b0b72dc..88d49d6db 100644 --- a/crates/aionui-app/tests/acp_e2e.rs +++ b/crates/aionui-app/tests/acp_e2e.rs @@ -103,6 +103,7 @@ async fn management_list_includes_missing_custom_agents() { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -158,6 +159,7 @@ async fn management_list_marks_rows_with_unavailable_snapshot() { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -239,6 +241,7 @@ async fn health_check_by_id_returns_missing_status_for_uninstalled_agent() { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, diff --git a/crates/aionui-app/tests/agent_integration_e2e.rs b/crates/aionui-app/tests/agent_integration_e2e.rs index bba382fa0..1f8642dee 100644 --- a/crates/aionui-app/tests/agent_integration_e2e.rs +++ b/crates/aionui-app/tests/agent_integration_e2e.rs @@ -233,6 +233,7 @@ async fn upsert_visible_agent_metadata(services: &aionui_app::AppServices, id: & args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: Some("{}"), yolo_id: Some("yolo"), agent_capabilities: None, @@ -391,6 +392,7 @@ async fn agent_logos_endpoint_includes_disabled_and_missing_rows() { args: Some("[]"), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: Some("{}"), yolo_id: Some("yolo"), agent_capabilities: None, @@ -697,6 +699,7 @@ async fn npx_bridged_agent_rejects_command_override() { args: Some(r#"["-y","@kilocode/cli","acp"]"#), env: Some("[]"), native_skills_dirs: None, + skill_delivery: None, behavior_policy: Some("{}"), yolo_id: None, agent_capabilities: None, diff --git a/crates/aionui-app/tests/assistants_e2e.rs b/crates/aionui-app/tests/assistants_e2e.rs index 139b95075..db2c6bb3b 100644 --- a/crates/aionui-app/tests/assistants_e2e.rs +++ b/crates/aionui-app/tests/assistants_e2e.rs @@ -96,6 +96,7 @@ fn test_agent_row(id: &str, backend: Option<&str>, agent_type: AgentType, name: args: Vec::new(), env: Vec::new(), native_skills_dirs: None, + skill_delivery: None, behavior_policy: BehaviorPolicy { supports_team: true, ..Default::default() diff --git a/crates/aionui-assistant/src/service.rs b/crates/aionui-assistant/src/service.rs index d20b61780..6db14c326 100644 --- a/crates/aionui-assistant/src/service.rs +++ b/crates/aionui-assistant/src/service.rs @@ -3845,6 +3845,7 @@ mod tests { args: Vec::new(), env: Vec::new(), native_skills_dirs: None, + skill_delivery: None, behavior_policy: aionui_api_types::BehaviorPolicy { supports_team: true, ..Default::default() diff --git a/crates/aionui-channel/tests/message_service_integration.rs b/crates/aionui-channel/tests/message_service_integration.rs index 6920735ed..81998a1b9 100644 --- a/crates/aionui-channel/tests/message_service_integration.rs +++ b/crates/aionui-channel/tests/message_service_integration.rs @@ -55,15 +55,6 @@ impl SkillResolver for NoopSkillResolver { async fn resolve_skills(&self, _names: &[String]) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &std::path::Path, - _rel_dirs: &[&str], - _skills: &[ResolvedAgentSkill], - ) -> usize { - 0 - } } struct ScriptedAgent { diff --git a/crates/aionui-common/src/enums.rs b/crates/aionui-common/src/enums.rs index 67bca5033..b73e633d1 100644 --- a/crates/aionui-common/src/enums.rs +++ b/crates/aionui-common/src/enums.rs @@ -73,10 +73,18 @@ impl AgentType { /// Native skill-discovery directories for non-ACP agent types. /// - /// ACP vendors own their skill dirs through the `agent_metadata` - /// table; this method covers the few non-ACP agent types that still - /// support native skill discovery. Returns `None` for agent types - /// that require prompt-injection instead of workspace symlinks. + /// ⚠️ RETIRED FROM SKILL DELIVERY. AionUi no longer creates these + /// directories: skills reach an agent through its own view directory + /// under the data dir (layer 1) or through prompt injection plus the + /// `aioncore skills` / `[LOAD_SKILL]` channels (layer 2). The delivery + /// decision is `agent_metadata.skill_delivery`, not this table. + /// + /// Kept as historical/reference data — a record of which directory each + /// non-ACP CLI scans — and asserted by the antigravity migration test to + /// keep the seeded row and this table in agreement. Do NOT reintroduce it + /// as a delivery signal: it conflated "declares a directory" with "can + /// discover skills", which is how a vendor could end up with neither + /// native discovery nor an injected index. /// /// `AgentType::Gemini` is intentionally absent: new Gemini /// conversations use `AgentType::Acp` with `backend = "gemini"`, so diff --git a/crates/aionui-conversation/src/response_middleware.rs b/crates/aionui-conversation/src/response_middleware.rs index b75ab96d8..1defa3af2 100644 --- a/crates/aionui-conversation/src/response_middleware.rs +++ b/crates/aionui-conversation/src/response_middleware.rs @@ -66,9 +66,28 @@ impl MessageMiddleware { } /// Process a completed agent message through the middleware pipeline. - pub async fn process(&self, message: &str, _user_id: &str, _conversation_id: &str) -> MiddlewareResult { + pub async fn process(&self, message: &str, _user_id: &str, conversation_id: &str) -> MiddlewareResult { + // TWO different sources, deliberately. Detection reads the RAW message + // because reasoning models routinely put `[LOAD_SKILL: …]` inside + // ``; display reads the stripped text because thinking is not for + // the user. Sharing one `cleaned` value silently dropped every in-think + // request -- and with no log, so it presented as "the model said it would + // use a skill, then nothing happened". + let skill_names = detect_skill_load_requests(message); let cleaned = strip_think_tags(message); - let skill_names = detect_skill_load_requests(&cleaned); + if !skill_names.is_empty() { + // `info`: this path had NO logging at all, which is exactly why the + // dropped-request defect could sit unnoticed. Same structured fields + // as the command channel so the split between the two is measurable. + let visible_requests = detect_skill_load_requests(&cleaned).len(); + tracing::info!( + conversation_id = %conversation_id, + channel = "load_skill_protocol", + skills = skill_names.len(), + from_thinking = visible_requests < skill_names.len(), + "skill load request detected" + ); + } let system_responses = self.load_requested_skills(&skill_names).await; MiddlewareResult { @@ -98,7 +117,17 @@ impl MessageMiddleware { loaded .into_iter() - .map(|skill| format!("[Skill: {}]\n{}", skill.name, skill.body)) + .map(|skill| { + // The root is stated up front so the agent resolves the body's own + // relative references (`references/…`, `scripts/…`) inside the + // skill instead of against its CWD. + format!( + "[Skill: {}]\nSkill root (resolve every relative path in this body against it): {}\n\n{}", + skill.name, + skill.source_path.display(), + skill.body + ) + }) .collect() } } @@ -143,27 +172,91 @@ mod tests { assert_eq!(requests, vec!["cron", "pdf"]); } - #[tokio::test] - async fn middleware_loads_requested_skills() { - struct MockSkillLoader; - - #[async_trait] - impl ISkillLoadService for MockSkillLoader { - async fn load_skill_bodies(&self, names: &[String]) -> Vec { - names - .iter() - .map(|name| LoadedAgentSkill { - name: name.clone(), - body: format!("body for {name}"), - }) - .collect() - } + struct MockSkillLoader; + + #[async_trait] + impl ISkillLoadService for MockSkillLoader { + async fn load_skill_bodies(&self, names: &[String]) -> Vec { + names + .iter() + .map(|name| LoadedAgentSkill { + name: name.clone(), + body: format!("body for {name}"), + source_path: std::path::PathBuf::from(format!("/src/{name}")), + }) + .collect() } + } + #[tokio::test] + async fn middleware_loads_requested_skills() { let mw = MessageMiddleware::new_with_skill_loader(Some(Box::new(MockSkillLoader))); let result = mw.process("Need [LOAD_SKILL: cron]", "user", "conv").await; assert_eq!(result.message, "Need [LOAD_SKILL: cron]"); - assert_eq!(result.system_responses, vec!["[Skill: cron]\nbody for cron"]); + // The injected block now also states the skill root -- see + // `the_injected_body_declares_the_skill_root_absolute_path` for why. + let injected = &result.system_responses[0]; + assert!(injected.starts_with("[Skill: cron]")); + assert!(injected.contains("body for cron")); + } + + /// P1: reasoning models routinely put this meta-decision INSIDE . + /// Detecting on the STRIPPED text made the request vanish with no log at all, + /// so the user saw "the model said it would use a skill, then nothing". + #[tokio::test] + async fn a_load_request_inside_a_think_block_is_still_honoured() { + let mw = MessageMiddleware::new_with_skill_loader(Some(Box::new(MockSkillLoader))); + let result = mw + .process( + "I need [LOAD_SKILL: cron] for thisSure, on it.", + "user", + "conv", + ) + .await; + + assert_eq!( + result.system_responses.len(), + 1, + "the request must be detected in the RAW text" + ); + assert!(result.system_responses[0].contains("cron")); + assert_eq!( + result.message, "Sure, on it.", + "the VISIBLE text is still stripped -- detection and display read different sources" + ); + } + + /// P1 boundary: the same skill inside and outside the block is one request. + #[tokio::test] + async fn a_request_repeated_inside_and_outside_the_block_loads_once() { + let mw = MessageMiddleware::new_with_skill_loader(Some(Box::new(MockSkillLoader))); + let result = mw + .process( + "[LOAD_SKILL: cron]Also [LOAD_SKILL: cron]", + "user", + "conv", + ) + .await; + assert_eq!(result.system_responses.len(), 1, "dedupe across the think boundary"); + } + + /// P2: without the root, a relative reference in the skill body resolves + /// against the CWD. The dangerous case is not "file missing" but "the + /// workspace happens to hold a same-named file", which points the agent at + /// unrelated user content. + #[tokio::test] + async fn the_injected_body_declares_the_skill_root_absolute_path() { + let mw = MessageMiddleware::new_with_skill_loader(Some(Box::new(MockSkillLoader))); + let result = mw.process("Need [LOAD_SKILL: cron]", "user", "conv").await; + + let injected = &result.system_responses[0]; + assert!( + injected.contains("/src/cron"), + "the skill root must be stated so `references/x.md` resolves inside the \ + skill rather than against the workspace: {injected}" + ); + assert!(injected.contains("[Skill: cron]"), "the existing header shape is kept"); + assert!(injected.contains("body for cron")); } } diff --git a/crates/aionui-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 720cf91f8..e37b2a119 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -1127,7 +1127,10 @@ impl ConversationService { .map(str::to_owned) }); - let auto_provisioned_workspace = if user_supplied_workspace.is_none() { + // Statement, not a binding: the path itself is no longer needed now that + // skill delivery does not write into the workspace. The side effects still + // are -- creating the directory and recording it in `extra.workspace`. + if user_supplied_workspace.is_none() { // Per-conversation temp workspaces live under // `{data_dir}/conversations/YYYY/MM/DD/{label}-temp-{id}/`. // The label lets operators eyeball the agent type; the @@ -1143,10 +1146,7 @@ impl ConversationService { std::fs::create_dir_all(&ws_path) .map_err(|e| ConversationError::internal(format!("Failed to create workspace: {e}")))?; extra["workspace"] = serde_json::Value::String(ws_path.to_string_lossy().into_owned()); - Some(ws_path) - } else { - None - }; + } // Strip the request-only custom_workspace toggle — it was read above // and must not be persisted as an extra field. @@ -1285,42 +1285,25 @@ impl ConversationService { let auto_inject_names = self.skill_resolver.auto_inject_names().await; let initial_skills = compute_initial_skills(&auto_inject_names, &preset_enabled, &exclude_auto_inject); - // Wire skill links into the runtime workspace so the agent CLI picks - // them up via its native skills dir (e.g. `.claude/skills/`). This - // applies to both temp and user-selected workspaces. - let skill_link_workspace = user_supplied_workspace - .as_ref() - .map(PathBuf::from) - .or_else(|| auto_provisioned_workspace.clone()); - if let Some(ws_path) = skill_link_workspace.as_ref() - && !initial_skills.is_empty() - && let Some(rel_dirs) = native_skills_dirs( - &self.agent_metadata_repo, - user_id, - &effective_type, - effective_backend - .as_ref() - .map(|backend| serde_json::Value::String(backend.clone())) - .as_ref(), - ) - .await - { + // Build the per-conversation skill VIEW under AionUi's own data dir. + // + // This REPLACED a step that symlinked the resolved skills into the + // workspace's native skills dir (`.claude/skills/` and friends) for both + // temp and user-selected workspaces. AionUi no longer writes there at + // all: the workspace may be a git repository, the directories were never + // cleaned up, and a failed symlink used to degrade into copying real + // files in. See `workspace_is_untouched_*` in service_test.rs. + // + // Unconditional on the delivery mode: the view is our own tree, so + // building it for every conversation means a mode flipped in the registry + // (a data-only change) needs no per-conversation backfill to take effect. + if !initial_skills.is_empty() { let resolved = self .skill_resolver .resolve_skills_for_user(user_id, &initial_skills) .await; if !resolved.is_empty() { - let rel_dirs_refs: Vec<&str> = rel_dirs.iter().map(String::as_str).collect(); - let n = self - .skill_resolver - .link_workspace_skills(ws_path, &rel_dirs_refs, &resolved) - .await; - debug!( - conversation_id = %id, - workspace = %ws_path.display(), - links = n, - "wired skill symlinks into workspace" - ); + self.skill_resolver.sync_skill_view(user_id, &id, &resolved).await; } } @@ -2628,6 +2611,11 @@ impl ConversationService { hook.on_conversation_deleted(user_id, id).await; } + // Drop the skill view while the row still exists: nothing downstream + // knows the (user, conversation) pair once it is gone, and a leaked view + // then waits for the next startup sweep. + self.skill_resolver.remove_skill_view(user_id, id).await; + if let Err(err) = self.conversation_repo.delete(user_id, id).await { self.runtime_state.clear_deleting(id); return Err(err.into()); @@ -4024,7 +4012,7 @@ impl ConversationService { } }; self.apply_conversation_runtime_context(&mut build_opts, user_id, conversation_id); - self.ensure_workspace_skill_links(&row, &build_opts).await; + self.ensure_session_skill_view(&build_opts.context).await; let stored_workspace = build_opts.context.workspace.stored_path.clone(); let user_msg_id_ret = user_msg_id.clone(); @@ -4146,7 +4134,7 @@ impl ConversationService { }; self.apply_conversation_runtime_context(&mut build_opts, &request.user_id, &request.conversation_id); - self.ensure_workspace_skill_links(&row, &build_opts).await; + self.ensure_session_skill_view(&build_opts.context).await; let stored_workspace = build_opts.context.workspace.stored_path.clone(); let conversation_id = request.conversation_id.clone(); let result = ConversationTurnOrchestrator::new(self.clone(), self.task_manager.clone()) @@ -4666,7 +4654,7 @@ impl ConversationService { let mut build_opts = self.build_task_options(&row).await?; self.apply_conversation_runtime_context(&mut build_opts, user_id, conversation_id); - self.ensure_workspace_skill_links(&row, &build_opts).await; + self.ensure_session_skill_view(&build_opts.context).await; let stored_workspace = build_opts.context.workspace.stored_path.clone(); let backend = build_options_backend(&build_opts).map(str::to_owned); let agent = match task_manager.get_or_build_task(conversation_id, build_opts).await { @@ -4873,47 +4861,17 @@ impl ConversationService { Some(issue.token) } - /// Ensure native skill links exist in the runtime workspace. Auto - /// workspaces are constrained to AionUi's generated path; custom - /// workspaces were validated when the session context was built. - pub(crate) async fn ensure_workspace_skill_links(&self, row: &ConversationRow, build_opts: &BuildTaskOptions) { - let context = &build_opts.context; - let backend = context_backend_value(context); - - let workspace = PathBuf::from(context.workspace.path.trim()); - if !context.workspace.is_custom { - let expected_workspace = expected_auto_workspace_path( - &self.workspace_root, - &row.user_id, - &row.id, - &context.conversation.agent_type, - backend.as_ref(), - ); - - if workspace != expected_workspace { - return; - } - } - + /// Rebuild this conversation's skill view directory from its snapshot. + /// + /// Idempotent and independent of the workspace: the view lives under + /// AionUi's data dir, so unlike the workspace wiring this needs no + /// auto-workspace-path guard and runs for every agent regardless of the + /// vendor's declared delivery mode. + pub(crate) async fn ensure_session_skill_view(&self, context: &AgentSessionContext) { let skill_names = context_skill_names(context); if skill_names.is_empty() { return; } - - let Some(rel_dirs) = native_skills_dirs( - &self.agent_metadata_repo, - &context.conversation.user_id, - &context.conversation.agent_type, - backend.as_ref(), - ) - .await - else { - return; - }; - if rel_dirs.is_empty() { - return; - } - let resolved = self .skill_resolver .resolve_skills_for_user(&context.conversation.user_id, &skill_names) @@ -4921,18 +4879,9 @@ impl ConversationService { if resolved.is_empty() { return; } - - let rel_dirs_refs: Vec<&str> = rel_dirs.iter().map(String::as_str).collect(); - let n = self - .skill_resolver - .link_workspace_skills(&workspace, &rel_dirs_refs, &resolved) + self.skill_resolver + .sync_skill_view(&context.conversation.user_id, context.conversation_id(), &resolved) .await; - debug!( - conversation_id = %row.id, - workspace = %workspace.display(), - links = n, - "ensured skill symlinks in auto workspace" - ); } /// Write the resolved workspace back to `conversation.extra.workspace` when @@ -5149,19 +5098,6 @@ fn conversation_label(agent_type: &AgentType, backend: Option<&serde_json::Value agent_type.serde_name().to_owned() } -fn expected_auto_workspace_path( - workspace_root: &std::path::Path, - user_id: &str, - conversation_id: &str, - agent_type: &AgentType, - backend: Option<&serde_json::Value>, -) -> PathBuf { - auto_workspace_parent(workspace_root, user_id).join(format!( - "{}-temp-{conversation_id}", - conversation_label(agent_type, backend) - )) -} - fn auto_workspace_parent(workspace_root: &Path, user_id: &str) -> PathBuf { let dir = aionui_common::user_dir_name(user_id).unwrap_or_else(|_| user_id.to_owned()); let now = chrono::Local::now(); @@ -5341,18 +5277,6 @@ fn is_dated_auto_workspace_relative_path(relative: &Path) -> bool { ) } -fn context_backend_value(context: &AgentSessionContext) -> Option { - match &context.kind { - AgentSessionKind::Acp(acp) => acp - .config - .backend - .as_ref() - .filter(|value| !value.is_empty()) - .map(|value| serde_json::Value::String(value.clone())), - _ => None, - } -} - fn build_options_backend(options: &BuildTaskOptions) -> Option<&str> { match &options.context.kind { AgentSessionKind::Acp(ctx) => ctx.config.backend.as_deref(), @@ -5365,36 +5289,6 @@ fn context_skill_names(context: &AgentSessionContext) -> Vec { context.skills.clone() } -/// Resolve the native skills directory list for an agent by looking it -/// up in the `agent_metadata` catalog (ACP vendors) or the bundled -/// `AgentType` table (non-ACP built-ins). -/// -/// Returns `None` when the agent does not support native skill -/// discovery — callers should then skip the workspace-symlink step and -/// rely on prompt injection instead. -async fn native_skills_dirs( - repo: &Arc, - user_id: &str, - agent_type: &AgentType, - backend: Option<&serde_json::Value>, -) -> Option> { - if *agent_type == AgentType::Acp - && let Some(serde_json::Value::String(vendor)) = backend - && !vendor.is_empty() - { - let row = repo - .find_builtin_by_backend_for_user(user_id, vendor) - .await - .ok() - .flatten()?; - let raw = row.native_skills_dirs?; - return serde_json::from_str::>(&raw).ok(); - } - agent_type - .native_skills_dirs() - .map(|dirs| dirs.iter().map(|s| (*s).to_owned()).collect()) -} - impl ConversationService { /// Build the typed four-field runtime MCP snapshot from explicit request /// selections (or the global enabled fallback when `selected_ids` is diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index e4879d7ea..aced1ba42 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -58,15 +58,16 @@ use crate::{ConversationAgentTurnRequest, ConversationAgentTurnStatus, Conversat mod acp_error_recovery_test; #[derive(Clone, Debug)] -struct SkillLinkCall { - workspace: PathBuf, - rel_dirs: Vec, +struct RecordedViewSync { + user_id: String, + conversation_id: String, skill_names: Vec, } struct RecordingSkillResolver { names: Vec, - links: Arc>>, + views: Arc>>, + view_removals: Arc>>, } struct StaticAssistantDispatcher { @@ -116,7 +117,8 @@ impl RecordingSkillResolver { fn new(names: Vec) -> Self { Self { names, - links: Arc::new(Mutex::new(Vec::new())), + views: Arc::new(Mutex::new(Vec::new())), + view_removals: Arc::new(Mutex::new(Vec::new())), } } } @@ -137,26 +139,20 @@ impl SkillResolver for RecordingSkillResolver { .collect() } - async fn link_workspace_skills(&self, workspace: &Path, rel_dirs: &[&str], skills: &[ResolvedAgentSkill]) -> usize { - self.links.lock().unwrap().push(SkillLinkCall { - workspace: workspace.to_path_buf(), - rel_dirs: rel_dirs.iter().map(|s| (*s).to_owned()).collect(), + async fn sync_skill_view(&self, user_id: &str, conversation_id: &str, skills: &[ResolvedAgentSkill]) -> usize { + self.views.lock().unwrap().push(RecordedViewSync { + user_id: user_id.to_owned(), + conversation_id: conversation_id.to_owned(), skill_names: skills.iter().map(|skill| skill.name.clone()).collect(), }); + skills.len() + } - let mut linked = 0; - for rel_dir in rel_dirs { - let target_dir = workspace.join(rel_dir); - if std::fs::create_dir_all(&target_dir).is_err() { - continue; - } - for skill in skills { - if std::fs::create_dir_all(target_dir.join(&skill.name)).is_ok() { - linked += 1; - } - } - } - linked + async fn remove_skill_view(&self, user_id: &str, conversation_id: &str) { + self.view_removals + .lock() + .unwrap() + .push((user_id.to_owned(), conversation_id.to_owned())); } } @@ -759,6 +755,7 @@ fn stub_agent_metadata_rows() -> Vec { args: Some("[]".to_owned()), env: Some("[]".to_owned()), native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, @@ -919,6 +916,7 @@ fn claude_metadata_row() -> AgentMetadataRow { args: Some("[]".into()), env: Some("[]".into()), native_skills_dirs: Some(r#"[".claude/skills"]"#.into()), + skill_delivery: None, behavior_policy: Some("{}".into()), yolo_id: Some("bypassPermissions".into()), agent_capabilities: None, @@ -8581,10 +8579,14 @@ async fn create_writes_empty_skills_when_no_auto_inject_and_no_preset() { assert_eq!(resp.extra["skills"], json!([])); } +/// Rewritten from `create_links_skills_into_custom_workspace_for_native_acp_agent`. +/// The old assertion (`workspace.join(".claude/skills/cron").is_dir()`) encoded +/// exactly the behaviour this refactor removes, so the SAME scenario now asserts +/// its replacement: nothing in the workspace, everything in the view. #[tokio::test] -async fn create_links_skills_into_custom_workspace_for_native_acp_agent() { +async fn create_puts_skills_in_the_view_not_in_a_custom_workspace() { let resolver = Arc::new(RecordingSkillResolver::new(vec!["cron".into()])); - let links = resolver.links.clone(); + let views = resolver.views.clone(); let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_resolver_and_agent_metadata_repo(resolver, Arc::new(ClaudeNativeSkillMetadataRepo)); let workspace = unique_test_workspace_path("custom-create"); @@ -8599,19 +8601,26 @@ async fn create_links_skills_into_custom_workspace_for_native_acp_agent() { .unwrap(); let resp = svc.create("user-1", req).await.unwrap(); - assert_eq!(resp.extra["skills"], json!(["cron"])); - assert!(workspace.join(".claude/skills/cron").is_dir()); - let calls = links.lock().unwrap(); + assert_eq!( + resp.extra["skills"], + json!(["cron"]), + "snapshot semantics are unchanged" + ); + assert!( + !workspace.join(".claude").exists(), + "G1: skill delivery must create nothing in a user-selected workspace" + ); + let calls = views.lock().unwrap(); assert_eq!(calls.len(), 1); - assert_eq!(calls[0].workspace, workspace); - assert_eq!(calls[0].rel_dirs, vec![".claude/skills"]); assert_eq!(calls[0].skill_names, vec!["cron"]); } +/// Rewritten from `warmup_restores_skill_links_for_recreated_auto_workspace`. +/// The recovery property is preserved -- it now recovers the VIEW. #[tokio::test] -async fn warmup_restores_skill_links_for_recreated_auto_workspace() { +async fn warmup_rebuilds_the_skill_view_for_a_recreated_auto_workspace() { let resolver = Arc::new(RecordingSkillResolver::new(vec!["cron".into()])); - let links = resolver.links.clone(); + let views = resolver.views.clone(); let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_resolver(resolver); let req: CreateConversationRequest = serde_json::from_value(json!({ @@ -8621,28 +8630,29 @@ async fn warmup_restores_skill_links_for_recreated_auto_workspace() { .unwrap(); let resp = svc.create("user-1", req).await.unwrap(); let workspace = PathBuf::from(resp.extra["workspace"].as_str().unwrap()); - assert!(workspace.join(".aionrs/skills/cron").is_dir()); + assert!( + !workspace.join(".aionrs").exists(), + "G1 holds for auto-provisioned workspaces too, not only user-selected ones" + ); std::fs::remove_dir_all(&workspace).unwrap(); - assert!(!workspace.exists()); - links.lock().unwrap().clear(); + views.lock().unwrap().clear(); let task_mgr: Arc = Arc::new(MockTaskManagerWithWorkspace::new(workspace.to_str().unwrap())); svc.warmup("user-1", &resp.id, &task_mgr).await.unwrap(); - assert!(workspace.join(".aionrs/skills/cron").is_dir()); - let calls = links.lock().unwrap(); - assert_eq!(calls.len(), 1); - assert_eq!(calls[0].workspace, workspace); - assert_eq!(calls[0].rel_dirs, vec![".aionrs/skills"]); + let calls = views.lock().unwrap(); + assert_eq!(calls.len(), 1, "warmup re-syncs the view"); assert_eq!(calls[0].skill_names, vec!["cron"]); + assert!(!workspace.join(".aionrs").exists(), "and still writes nothing there"); } +/// Rewritten from `warmup_restores_skill_links_for_custom_workspace`. #[tokio::test] -async fn warmup_restores_skill_links_for_custom_workspace() { +async fn warmup_rebuilds_the_skill_view_for_a_custom_workspace() { let resolver = Arc::new(RecordingSkillResolver::new(vec!["cron".into()])); - let links = resolver.links.clone(); + let views = resolver.views.clone(); let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_resolver_and_agent_metadata_repo(resolver, Arc::new(ClaudeNativeSkillMetadataRepo)); let workspace = unique_test_workspace_path("custom-warmup"); @@ -8656,20 +8666,285 @@ async fn warmup_restores_skill_links_for_custom_workspace() { })) .unwrap(); let resp = svc.create("user-1", req).await.unwrap(); + views.lock().unwrap().clear(); + + let task_mgr: Arc = + Arc::new(MockTaskManagerWithWorkspace::new(workspace.to_str().unwrap())); + svc.warmup("user-1", &resp.id, &task_mgr).await.unwrap(); + + let calls = views.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].skill_names, vec!["cron"]); + assert!( + !workspace.join(".claude").exists(), + "G1: still nothing in the workspace" + ); +} + +// ── G1 regression: the workspace is never written to ──────────────── +// +// The single most important assertion in this refactor. AionUi's skill delivery +// used to symlink into `{workspace}/.claude/skills` and friends -- for BOTH +// temp and user-selected workspaces, the latter frequently being a git +// repository. The directories were never cleaned up, a manual delete was +// re-created on the next build, and a failed symlink degraded into copying real +// files in. +// +// Scope: the SKILL DELIVERY path. There is exactly one acknowledged exception, +// `.agents/hooks.json`, written by `aionui-ai-agent`'s antigravity hook -- it is +// agy's PreToolUse permission gate and load-bearing, so removing it would +// silently downgrade agy's security. It is whitelisted BY NAME below, so any +// OTHER new workspace write still fails these tests. + +const G1_ALLOWED_WORKSPACE_PATHS: &[&str] = &[".agents", ".agents/hooks.json"]; + +fn workspace_snapshot(root: &Path) -> std::collections::BTreeSet { + fn walk(root: &Path, dir: &Path, out: &mut std::collections::BTreeSet) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + let rel = path.strip_prefix(root).unwrap().to_string_lossy().replace('\\', "/"); + out.insert(rel); + // `symlink_metadata`, never `metadata`: descending THROUGH a link + // would pull a linked skill's own files into the snapshot and make + // this test report changes that are not the workspace's. + if path.symlink_metadata().map(|m| m.is_dir()).unwrap_or(false) { + walk(root, &path, out); + } + } + } + let mut out = std::collections::BTreeSet::new(); + walk(root, root, &mut out); + out +} + +fn assert_workspace_unchanged(before: &std::collections::BTreeSet, after: &std::collections::BTreeSet) { + let added: Vec<&String> = after + .difference(before) + .filter(|path| !G1_ALLOWED_WORKSPACE_PATHS.contains(&path.as_str())) + .collect(); + let removed: Vec<&String> = before.difference(after).collect(); + assert!(added.is_empty(), "AionUi created files in the workspace: {added:?}"); + assert!( + removed.is_empty(), + "AionUi removed files from the workspace: {removed:?}" + ); +} + +/// A USER-SELECTED workspace with pre-existing content, including the user's OWN +/// `.claude/skills/` -- the git-repository case that motivated G1. +#[tokio::test] +async fn workspace_is_untouched_for_a_user_selected_workspace() { + let resolver = Arc::new(RecordingSkillResolver::new(vec!["cron".into(), "officecli".into()])); + let (svc, _broadcaster, _repo, _task_mgr) = + make_service_with_resolver_and_agent_metadata_repo(resolver, Arc::new(ClaudeNativeSkillMetadataRepo)); + let workspace = unique_test_workspace_path("g1-user-selected"); + for rel in [ + "src/main.rs", + ".claude/skills/my-own-skill/SKILL.md", + "references/workflows.md", + ] { + let target = workspace.join(rel); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, "user content").unwrap(); + } + let before = workspace_snapshot(&workspace); + + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { "workspace": workspace, "backend": "claude" }, + })) + .unwrap(); + let resp = svc.create("user-1", req).await.unwrap(); + let task_mgr: Arc = + Arc::new(MockTaskManagerWithWorkspace::new(workspace.to_str().unwrap())); + svc.warmup("user-1", &resp.id, &task_mgr).await.unwrap(); + + assert_workspace_unchanged(&before, &workspace_snapshot(&workspace)); +} + +/// The AUTO-PROVISIONED workspace must be covered too. The removed code took it +/// as licence to write ("applies to both temp and user-selected workspaces"), so +/// testing only the user-selected case would miss half the defect. +#[tokio::test] +async fn workspace_is_untouched_for_an_auto_provisioned_workspace() { + let resolver = Arc::new(RecordingSkillResolver::new(vec!["cron".into()])); + let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_resolver(resolver); + + let req: CreateConversationRequest = serde_json::from_value(json!({ "type": "aionrs", "extra": {} })).unwrap(); + let resp = svc.create("user-1", req).await.unwrap(); + let workspace = PathBuf::from(resp.extra["workspace"].as_str().unwrap()); + let before = workspace_snapshot(&workspace); + + let task_mgr: Arc = + Arc::new(MockTaskManagerWithWorkspace::new(workspace.to_str().unwrap())); + svc.warmup("user-1", &resp.id, &task_mgr).await.unwrap(); + + assert_workspace_unchanged(&before, &workspace_snapshot(&workspace)); +} + +/// Team members share ONE workspace (the leader's), and the removed code stacked +/// a directory per member per vendor into it. Five conversations of different +/// vendors over one directory is the shape that used to accumulate. +#[tokio::test] +async fn workspace_is_untouched_when_five_vendors_share_one_workspace() { + let resolver = Arc::new(RecordingSkillResolver::new(vec!["cron".into()])); + let (svc, _broadcaster, _repo, _task_mgr) = + make_service_with_resolver_and_agent_metadata_repo(resolver, Arc::new(ClaudeNativeSkillMetadataRepo)); + let workspace = unique_test_workspace_path("g1-team-shared"); + std::fs::write(workspace.join("shared.txt"), "leader content").unwrap(); + let before = workspace_snapshot(&workspace); + + for backend in ["claude", "codex", "codebuddy", "antigravity", "opencode"] { + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { "workspace": workspace, "backend": backend }, + })) + .unwrap(); + let resp = svc.create("user-1", req).await.unwrap(); + let task_mgr: Arc = + Arc::new(MockTaskManagerWithWorkspace::new(workspace.to_str().unwrap())); + svc.warmup("user-1", &resp.id, &task_mgr).await.unwrap(); + } + + assert_workspace_unchanged(&before, &workspace_snapshot(&workspace)); +} + +/// A second build must not resurrect the links either: the removed code ran on +/// every build-task, which is why deleting the directories by hand never stuck. +#[tokio::test] +async fn workspace_is_untouched_on_a_second_build() { + let resolver = Arc::new(RecordingSkillResolver::new(vec!["cron".into()])); + let (svc, _broadcaster, _repo, _task_mgr) = + make_service_with_resolver_and_agent_metadata_repo(resolver, Arc::new(ClaudeNativeSkillMetadataRepo)); + let workspace = unique_test_workspace_path("g1-second-build"); + + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { "workspace": workspace, "backend": "claude" }, + })) + .unwrap(); + let resp = svc.create("user-1", req).await.unwrap(); + let task_mgr: Arc = + Arc::new(MockTaskManagerWithWorkspace::new(workspace.to_str().unwrap())); + svc.warmup("user-1", &resp.id, &task_mgr).await.unwrap(); + let before = workspace_snapshot(&workspace); + + svc.warmup("user-1", &resp.id, &task_mgr).await.unwrap(); + + assert_workspace_unchanged(&before, &workspace_snapshot(&workspace)); +} + +/// The view directory is the new landing site. This asserts it is built from the +/// snapshot; it says nothing about the workspace yet -- both paths coexist +/// deliberately until the workspace delivery is removed. +#[tokio::test] +async fn create_builds_the_session_skill_view_from_the_snapshot() { + let resolver = Arc::new(RecordingSkillResolver::new(vec!["cron".into()])); + let views = resolver.views.clone(); + let (svc, _broadcaster, _repo, _task_mgr) = + make_service_with_resolver_and_agent_metadata_repo(resolver, Arc::new(ClaudeNativeSkillMetadataRepo)); + + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { "workspace": ensure_test_workspace_path(), "backend": "claude" }, + })) + .unwrap(); + let resp = svc.create("user-1", req).await.unwrap(); + + let calls = views.lock().unwrap(); + assert_eq!(calls.len(), 1, "exactly one view rebuild per created conversation"); + assert_eq!(calls[0].user_id, "user-1"); + assert_eq!(calls[0].conversation_id, resp.id); + assert_eq!(calls[0].skill_names, vec!["cron"]); +} + +/// The view is built for EVERY agent, not only for vendors that consume it, so +/// flipping a delivery mode in the registry needs no per-conversation backfill. +/// This agent has no `native_skills_dirs` at all, which used to mean "no skill +/// wiring happens". +#[tokio::test] +async fn create_builds_the_view_even_for_an_agent_without_native_skill_dirs() { + let resolver = Arc::new(RecordingSkillResolver::new(vec!["cron".into()])); + let views = resolver.views.clone(); + let (svc, _broadcaster, _repo, _task_mgr) = make_service_with_resolver(resolver); + + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { "workspace": ensure_test_workspace_path(), "backend": "opencode" }, + })) + .unwrap(); + svc.create("user-1", req).await.unwrap(); + + assert_eq!(views.lock().unwrap().len(), 1); +} + +/// An empty snapshot must not produce a view rebuild: there is nothing to link, +/// and an empty plugin root would still cost the agent an always-on token line. +#[tokio::test] +async fn create_with_no_skills_does_not_build_a_view() { + let resolver = Arc::new(RecordingSkillResolver::new(Vec::new())); + let views = resolver.views.clone(); + let (svc, _broadcaster, _repo, _task_mgr) = + make_service_with_resolver_and_agent_metadata_repo(resolver, Arc::new(ClaudeNativeSkillMetadataRepo)); + + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { "workspace": ensure_test_workspace_path(), "backend": "claude" }, + })) + .unwrap(); + svc.create("user-1", req).await.unwrap(); + + assert!(views.lock().unwrap().is_empty()); +} + +/// Deleting a conversation must drop its view -- nothing else will, and the +/// owner is only knowable while the row still exists. +#[tokio::test] +async fn deleting_a_conversation_removes_its_skill_view() { + let resolver = Arc::new(RecordingSkillResolver::new(vec!["cron".into()])); + let removals = resolver.view_removals.clone(); + let (svc, _broadcaster, _repo, _task_mgr) = + make_service_with_resolver_and_agent_metadata_repo(resolver, Arc::new(ClaudeNativeSkillMetadataRepo)); + + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { "workspace": ensure_test_workspace_path(), "backend": "claude" }, + })) + .unwrap(); + let resp = svc.create("user-1", req).await.unwrap(); + svc.delete("user-1", &resp.id).await.unwrap(); - std::fs::remove_dir_all(workspace.join(".claude")).unwrap(); - assert!(!workspace.join(".claude/skills/cron").exists()); - links.lock().unwrap().clear(); + let calls = removals.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0], ("user-1".to_owned(), resp.id.clone())); +} + +/// Warmup re-syncs the view idempotently: build-task runs on every session open, +/// and a view removed out-of-band must come back. +#[tokio::test] +async fn warmup_resyncs_the_skill_view() { + let resolver = Arc::new(RecordingSkillResolver::new(vec!["cron".into()])); + let views = resolver.views.clone(); + let (svc, _broadcaster, _repo, _task_mgr) = + make_service_with_resolver_and_agent_metadata_repo(resolver, Arc::new(ClaudeNativeSkillMetadataRepo)); + let workspace = unique_test_workspace_path("view-warmup"); + + let req: CreateConversationRequest = serde_json::from_value(json!({ + "type": "acp", + "extra": { "workspace": workspace, "backend": "claude" }, + })) + .unwrap(); + let resp = svc.create("user-1", req).await.unwrap(); + views.lock().unwrap().clear(); let task_mgr: Arc = Arc::new(MockTaskManagerWithWorkspace::new(workspace.to_str().unwrap())); svc.warmup("user-1", &resp.id, &task_mgr).await.unwrap(); - assert!(workspace.join(".claude/skills/cron").is_dir()); - let calls = links.lock().unwrap(); + let calls = views.lock().unwrap(); assert_eq!(calls.len(), 1); - assert_eq!(calls[0].workspace, workspace); - assert_eq!(calls[0].rel_dirs, vec![".claude/skills"]); + assert_eq!(calls[0].conversation_id, resp.id); assert_eq!(calls[0].skill_names, vec!["cron"]); } @@ -9214,10 +9489,7 @@ async fn a_deferred_cancel_does_not_leak_into_a_later_turn() { async fn session_not_found_clears_the_persisted_session_id() { let acp_repo = Arc::new(StubAcpSessionRepo::with_session_id("df6f811c-dead-session")); let (svc, _broadcaster, _repo, task_mgr) = make_service_with_resolver_and_acp_session_repo( - Arc::new(RecordingSkillResolver { - names: Vec::new(), - links: Arc::new(Mutex::new(Vec::new())), - }), + Arc::new(RecordingSkillResolver::new(Vec::new())), acp_repo.clone(), ); @@ -9252,10 +9524,7 @@ async fn other_terminal_errors_keep_the_session_id_for_replay() { ] { let acp_repo = Arc::new(StubAcpSessionRepo::with_session_id("sess-live")); let (svc, _b, _r, task_mgr) = make_service_with_resolver_and_acp_session_repo( - Arc::new(RecordingSkillResolver { - names: Vec::new(), - links: Arc::new(Mutex::new(Vec::new())), - }), + Arc::new(RecordingSkillResolver::new(Vec::new())), acp_repo.clone(), ); @@ -9283,10 +9552,7 @@ async fn other_terminal_errors_keep_the_session_id_for_replay() { async fn a_clean_finish_leaves_the_session_id_alone() { let acp_repo = Arc::new(StubAcpSessionRepo::with_session_id("live-session")); let (svc, _b, _r, task_mgr) = make_service_with_resolver_and_acp_session_repo( - Arc::new(RecordingSkillResolver { - names: Vec::new(), - links: Arc::new(Mutex::new(Vec::new())), - }), + Arc::new(RecordingSkillResolver::new(Vec::new())), acp_repo.clone(), ); @@ -9315,10 +9581,7 @@ async fn a_clean_finish_leaves_the_session_id_alone() { async fn session_not_found_during_task_build_clears_the_persisted_session_id() { let acp_repo = Arc::new(StubAcpSessionRepo::with_session_id("deb7c49d-dead")); let (svc, _b, _r, _task_mgr) = make_service_with_resolver_and_acp_session_repo( - Arc::new(RecordingSkillResolver { - names: Vec::new(), - links: Arc::new(Mutex::new(Vec::new())), - }), + Arc::new(RecordingSkillResolver::new(Vec::new())), acp_repo.clone(), ); @@ -9337,10 +9600,7 @@ async fn session_not_found_during_task_build_clears_the_persisted_session_id() { async fn other_build_failures_keep_the_persisted_session_id() { let acp_repo = Arc::new(StubAcpSessionRepo::with_session_id("sess-live")); let (svc, _b, _r, _task_mgr) = make_service_with_resolver_and_acp_session_repo( - Arc::new(RecordingSkillResolver { - names: Vec::new(), - links: Arc::new(Mutex::new(Vec::new())), - }), + Arc::new(RecordingSkillResolver::new(Vec::new())), acp_repo.clone(), ); diff --git a/crates/aionui-conversation/src/session_context.rs b/crates/aionui-conversation/src/session_context.rs index 94844194c..bf719225d 100644 --- a/crates/aionui-conversation/src/session_context.rs +++ b/crates/aionui-conversation/src/session_context.rs @@ -862,6 +862,7 @@ mod tests { args: None, env: None, native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, diff --git a/crates/aionui-conversation/src/skill_resolver.rs b/crates/aionui-conversation/src/skill_resolver.rs index 30a731249..205ffb63c 100644 --- a/crates/aionui-conversation/src/skill_resolver.rs +++ b/crates/aionui-conversation/src/skill_resolver.rs @@ -2,11 +2,14 @@ //! `ConversationService` can compute the initial snapshot without forcing //! every test setup to stand up a real `SkillPaths` and skill repository. -use std::path::Path; use std::sync::Arc; use aionui_db::ISkillRepository; pub use aionui_extension::ResolvedAgentSkill; +// Frontmatter stripping lives in `aionui-extension` so this channel and the +// `aioncore skills show` command return byte-identical bodies. A local copy is +// how the two would quietly drift. +use aionui_extension::skill_service::extract_skill_body; use async_trait::async_trait; use tracing::warn; @@ -14,6 +17,14 @@ use tracing::warn; pub struct LoadedAgentSkill { pub name: String, pub body: String, + /// Absolute skill root. + /// + /// Load-bearing, not decoration: skill bodies reference their own files + /// relatively (`references/workflows.md`, `scripts/init_skill.py`), and + /// without a stated root the agent resolves those against its CWD -- the + /// workspace. That either fails silently or, worse, reads an unrelated + /// same-named user file. + pub source_path: std::path::PathBuf, } #[async_trait] @@ -44,11 +55,26 @@ pub trait SkillResolver: Send + Sync { load_resolved_skill_bodies(&resolved).await } - /// Create symlinks pointing at each resolved skill inside the given - /// workspace's per-backend native skills directories. `rel_dirs` is - /// the list of relative paths (e.g. `.claude/skills`) to populate. - /// Returns the number of symlinks successfully created. - async fn link_workspace_skills(&self, workspace: &Path, rel_dirs: &[&str], skills: &[ResolvedAgentSkill]) -> usize; + /// Rebuild this conversation's skill VIEW directory under AionUi's own data + /// dir, so the agent CLI can be pointed at it instead of at the user's + /// workspace. Returns the number of links created. + /// + /// Called unconditionally, not only for vendors that consume it: the view is + /// AionUi's own tree, and building it for every conversation means a delivery + /// mode flipped in the registry needs no per-conversation backfill. + /// + /// Defaults to a no-op so the many test stubs in this workspace do not each + /// need a body. The default cannot mask a PRODUCTION regression: the real + /// implementation is covered by + /// `extension_resolver_syncs_and_removes_a_real_view_directory` below, plus + /// the service-level tests that assert this method was actually called. + async fn sync_skill_view(&self, _user_id: &str, _conversation_id: &str, _skills: &[ResolvedAgentSkill]) -> usize { + 0 + } + + /// Drop this conversation's skill view directory. No-op by default, for the + /// same reason as [`Self::sync_skill_view`]. + async fn remove_skill_view(&self, _user_id: &str, _conversation_id: &str) {} } /// Production adapter backed by `aionui_extension::skill_service`. @@ -71,6 +97,7 @@ async fn load_resolved_skill_bodies(skills: &[ResolvedAgentSkill]) -> Vec loaded.push(LoadedAgentSkill { name: skill.name.clone(), body: extract_skill_body(&content), + source_path: skill.source_path.clone(), }), Err(e) => { warn!( @@ -85,21 +112,6 @@ async fn load_resolved_skill_bodies(skills: &[ResolvedAgentSkill]) -> Vec String { - let trimmed = content.trim_start(); - if !trimmed.starts_with("---") { - return content.to_string(); - } - - let after_open = &trimmed[3..]; - if let Some(close_idx) = after_open.find("---") { - let after_close = &after_open[close_idx + 3..]; - after_close.trim_start_matches('\n').to_string() - } else { - content.to_string() - } -} - #[async_trait] impl SkillResolver for ExtensionSkillResolver { async fn auto_inject_names(&self) -> Vec { @@ -143,7 +155,7 @@ impl SkillResolver for ExtensionSkillResolver { &self.paths, self.skill_repo.as_ref(), user_id, - "workspace-link", + "skill-resolve", names, ) .await @@ -159,22 +171,36 @@ impl SkillResolver for ExtensionSkillResolver { } } - async fn link_workspace_skills(&self, workspace: &Path, rel_dirs: &[&str], skills: &[ResolvedAgentSkill]) -> usize { - if rel_dirs.is_empty() || skills.is_empty() { - return 0; - } - match aionui_extension::link_workspace_skills(workspace, rel_dirs, skills).await { + async fn sync_skill_view(&self, user_id: &str, conversation_id: &str, skills: &[ResolvedAgentSkill]) -> usize { + match aionui_extension::skill_view::rebuild_view(&self.paths.data_dir, user_id, conversation_id, skills).await { Ok(n) => n, Err(e) => { - tracing::warn!( - workspace = %workspace.display(), + // `error`, not `warn`: the view is layer 1's only channel, so an + // unwritable one means native delivery is unavailable for this + // session. Not fatal -- layer 2's dual channel still covers it, + // so the conversation must still start. + tracing::error!( + user_id = %user_id, + conversation_id = %conversation_id, error = %e, - "link_workspace_skills failed" + "sync_skill_view failed; native skill delivery unavailable for this session" ); 0 } } } + + async fn remove_skill_view(&self, user_id: &str, conversation_id: &str) { + if let Err(e) = aionui_extension::skill_view::remove_view(&self.paths.data_dir, user_id, conversation_id).await + { + tracing::warn!( + user_id = %user_id, + conversation_id = %conversation_id, + error = %e, + "remove_skill_view failed; the orphan view will be reaped at next startup" + ); + } + } } #[cfg(test)] @@ -192,21 +218,13 @@ impl SkillResolver for FixedSkillResolver { async fn resolve_skills(&self, _names: &[String]) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &Path, - _rel_dirs: &[&str], - _skills: &[ResolvedAgentSkill], - ) -> usize { - 0 - } } #[cfg(test)] mod tests { use super::*; use aionui_db::{SqliteSkillRepository, UpsertSkillParams}; + use std::path::Path; fn write_skill(dir: &Path, name: &str, description: &str) { let skill_dir = dir.join(name); @@ -224,6 +242,46 @@ mod tests { assert_eq!(extract_skill_body(content), "Cron body"); } + /// `sync_skill_view` / `remove_skill_view` default to no-ops on the trait so + /// the workspace's many test stubs need no bodies. This test is what keeps + /// that default from masking a production regression: it drives the REAL + /// implementation and asserts the view directory actually appears on disk + /// under `{data_dir}/session-skills/{user}/{conversation}/`. + #[tokio::test] + async fn extension_resolver_syncs_and_removes_a_real_view_directory() { + let tmp = tempfile::TempDir::new().unwrap(); + let paths = Arc::new(aionui_extension::SkillPaths { + data_dir: tmp.path().to_path_buf(), + user_skills_dir: tmp.path().join("skills"), + cron_skills_dir: tmp.path().join("cron").join("skills"), + builtin_skills_dir: tmp.path().join("builtin-skills"), + builtin_rules_dir: tmp.path().join("builtin-rules"), + assistant_rules_dir: tmp.path().join("assistant-rules"), + assistant_skills_dir: tmp.path().join("assistant-skills"), + }); + let sources = tmp.path().join("sources"); + write_skill(&sources, "cron", "Schedule stuff"); + + let db = aionui_db::init_database_memory().await.unwrap(); + let repo: Arc = Arc::new(SqliteSkillRepository::new(db.pool().clone())); + let resolver = ExtensionSkillResolver::new(paths.clone(), repo); + + let resolved = vec![ResolvedAgentSkill { + name: "cron".to_owned(), + source_path: sources.join("cron"), + }]; + assert_eq!(resolver.sync_skill_view("user_a", "conv_1", &resolved).await, 1); + + let view = aionui_extension::skill_view::view_dir(&paths.data_dir, "user_a", "conv_1").unwrap(); + assert!(view.join(".claude-plugin").join("plugin.json").is_file()); + assert!(view.join("skills").join("cron").join("SKILL.md").is_file()); + + resolver.remove_skill_view("user_a", "conv_1").await; + assert!(!view.exists()); + // The link is gone; the real source must not be. + assert!(sources.join("cron").join("SKILL.md").is_file()); + } + #[tokio::test] async fn extension_resolver_reads_auto_inject_names_from_skill_catalog() { let tmp = tempfile::TempDir::new().unwrap(); diff --git a/crates/aionui-conversation/src/stream_relay.rs b/crates/aionui-conversation/src/stream_relay.rs index 8e9441a4a..31fd75951 100644 --- a/crates/aionui-conversation/src/stream_relay.rs +++ b/crates/aionui-conversation/src/stream_relay.rs @@ -1141,18 +1141,10 @@ mod tests { .map(|name| LoadedAgentSkill { name: name.clone(), body: format!("{name} body"), + source_path: std::path::PathBuf::from(format!("/src/{name}")), }) .collect() } - - async fn link_workspace_skills( - &self, - _workspace: &std::path::Path, - _rel_dirs: &[&str], - _skills: &[aionui_extension::ResolvedAgentSkill], - ) -> usize { - 0 - } } #[tokio::test] diff --git a/crates/aionui-conversation/tests/active_lease.rs b/crates/aionui-conversation/tests/active_lease.rs index 6ec12f5d4..17c7b74cf 100644 --- a/crates/aionui-conversation/tests/active_lease.rs +++ b/crates/aionui-conversation/tests/active_lease.rs @@ -65,15 +65,6 @@ impl SkillResolver for EmptySkillResolver { async fn resolve_skills(&self, _names: &[String]) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &std::path::Path, - _rel_dirs: &[&str], - _skills: &[aionui_extension::ResolvedAgentSkill], - ) -> usize { - 0 - } } const USER_ID: &str = "system_default_user"; diff --git a/crates/aionui-conversation/tests/conversation_crud.rs b/crates/aionui-conversation/tests/conversation_crud.rs index d0a9288f9..181b37c46 100644 --- a/crates/aionui-conversation/tests/conversation_crud.rs +++ b/crates/aionui-conversation/tests/conversation_crud.rs @@ -80,15 +80,6 @@ impl SkillResolver for EmptySkillResolver { async fn resolve_skills(&self, _names: &[String]) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &std::path::Path, - _rel_dirs: &[&str], - _skills: &[aionui_extension::ResolvedAgentSkill], - ) -> usize { - 0 - } } async fn setup() -> (ConversationService, Arc, Arc) { diff --git a/crates/aionui-conversation/tests/conversation_extended.rs b/crates/aionui-conversation/tests/conversation_extended.rs index 6a94c1745..1decf478b 100644 --- a/crates/aionui-conversation/tests/conversation_extended.rs +++ b/crates/aionui-conversation/tests/conversation_extended.rs @@ -77,15 +77,6 @@ impl SkillResolver for EmptySkillResolver { async fn resolve_skills(&self, _names: &[String]) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &std::path::Path, - _rel_dirs: &[&str], - _skills: &[aionui_extension::ResolvedAgentSkill], - ) -> usize { - 0 - } } async fn setup() -> ( diff --git a/crates/aionui-conversation/tests/skill_load_protocol.rs b/crates/aionui-conversation/tests/skill_load_protocol.rs new file mode 100644 index 000000000..c912af41b --- /dev/null +++ b/crates/aionui-conversation/tests/skill_load_protocol.rs @@ -0,0 +1,90 @@ +//! Channel B (`[LOAD_SKILL: name]`) end-to-end through the middleware. +//! +//! The scenario here is the one that made P2 worth fixing on its own, ahead of +//! any directory allow-listing: it is not "the referenced file is missing" but +//! "the workspace happens to hold a file with the SAME relative name". Without a +//! stated skill root, the agent resolves `references/workflows.md` against its +//! CWD and reads unrelated user content while believing it read the skill's. + +use std::path::PathBuf; + +use aionui_conversation::response_middleware::{ISkillLoadService, MessageMiddleware}; +use aionui_conversation::skill_resolver::LoadedAgentSkill; + +struct FixedLoader { + root: PathBuf, +} + +#[async_trait::async_trait] +impl ISkillLoadService for FixedLoader { + async fn load_skill_bodies(&self, names: &[String]) -> Vec { + names + .iter() + .map(|name| LoadedAgentSkill { + name: name.clone(), + // The real skill-creator body references its own files relatively. + body: "See references/workflows.md and run scripts/init_skill.py".to_owned(), + source_path: self.root.clone(), + }) + .collect() + } +} + +#[tokio::test] +async fn a_same_named_workspace_file_does_not_shadow_the_skill_reference() { + let tmp = tempfile::TempDir::new().unwrap(); + + // The workspace holds a DECOY at the exact relative path the body references. + let workspace = tmp.path().join("workspace"); + std::fs::create_dir_all(workspace.join("references")).unwrap(); + std::fs::write(workspace.join("references").join("workflows.md"), "USER-SECRET").unwrap(); + + let skill_root = tmp.path().join("sources").join("skill-creator"); + std::fs::create_dir_all(skill_root.join("references")).unwrap(); + std::fs::write(skill_root.join("references").join("workflows.md"), "SKILL-CONTENT-9931").unwrap(); + + let result = MessageMiddleware::new_with_skill_loader(Some(Box::new(FixedLoader { + root: skill_root.clone(), + }))) + .process("Need [LOAD_SKILL: skill-creator]", "user_a", "conv_1") + .await; + + let injected = &result.system_responses[0]; + assert!( + injected.contains(&skill_root.display().to_string()), + "the injected text must anchor relative references to the skill root: {injected}" + ); + assert!( + !injected.contains(&workspace.display().to_string()), + "nothing may point the agent at the workspace copy: {injected}" + ); + // The body's own relative references still travel verbatim -- the fix adds a + // root, it does not rewrite the skill. + assert!(injected.contains("references/workflows.md")); + assert!(injected.contains("scripts/init_skill.py")); +} + +/// Channel B must work with no HTTP endpoint and no command execution: that is +/// the whole reason it stays as the fallback leg. This exercise touches only the +/// middleware, so a green result here means an agent in a read-only or +/// plan-permission mode can still load a skill. +#[tokio::test] +async fn channel_b_needs_no_command_execution_or_endpoint() { + let tmp = tempfile::TempDir::new().unwrap(); + let skill_root = tmp.path().join("cron"); + std::fs::create_dir_all(&skill_root).unwrap(); + + let result = MessageMiddleware::new_with_skill_loader(Some(Box::new(FixedLoader { + root: skill_root.clone(), + }))) + .process( + "this needs [LOAD_SKILL: cron]Working on it.", + "user_a", + "conv_1", + ) + .await; + + assert_eq!(result.system_responses.len(), 1); + assert!(result.system_responses[0].contains("[Skill: cron]")); + assert_eq!(result.message, "Working on it."); +} diff --git a/crates/aionui-conversation/tests/stream_relay_tool_call.rs b/crates/aionui-conversation/tests/stream_relay_tool_call.rs index e56fcd494..8059a07e7 100644 --- a/crates/aionui-conversation/tests/stream_relay_tool_call.rs +++ b/crates/aionui-conversation/tests/stream_relay_tool_call.rs @@ -32,15 +32,6 @@ impl SkillResolver for EmptySkillResolver { async fn resolve_skills(&self, _names: &[String]) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &std::path::Path, - _rel_dirs: &[&str], - _skills: &[ResolvedAgentSkill], - ) -> usize { - 0 - } } async fn setup_repo() -> (Arc, aionui_db::Database) { diff --git a/crates/aionui-conversation/tests/thinking_persistence.rs b/crates/aionui-conversation/tests/thinking_persistence.rs index d192f13bc..bf67e07a4 100644 --- a/crates/aionui-conversation/tests/thinking_persistence.rs +++ b/crates/aionui-conversation/tests/thinking_persistence.rs @@ -80,15 +80,6 @@ impl SkillResolver for EmptySkillResolver { async fn resolve_skills(&self, _names: &[String]) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &std::path::Path, - _rel_dirs: &[&str], - _skills: &[aionui_extension::ResolvedAgentSkill], - ) -> usize { - 0 - } } fn tool_call(call_id: &str) -> AgentStreamEvent { diff --git a/crates/aionui-cron/src/executor.rs b/crates/aionui-cron/src/executor.rs index 70a16ac7b..3d52a7afa 100644 --- a/crates/aionui-cron/src/executor.rs +++ b/crates/aionui-cron/src/executor.rs @@ -2623,15 +2623,6 @@ mod tests { ) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &std::path::Path, - _rel_dirs: &[&str], - _skills: &[aionui_conversation::skill_resolver::ResolvedAgentSkill], - ) -> usize { - 0 - } } let stub_broadcaster: Arc = Arc::new(StubBroadcaster); @@ -3383,15 +3374,6 @@ mod tests { ) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &std::path::Path, - _rel_dirs: &[&str], - _skills: &[aionui_conversation::skill_resolver::ResolvedAgentSkill], - ) -> usize { - 0 - } } let agent_metadata_repo: Arc = Arc::new(StubAgentMetadataRepo); diff --git a/crates/aionui-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 652c99ce1..f4f12df57 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -925,15 +925,6 @@ async fn setup_with_conv_runtime_and_agent_metadata() -> ( ) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &std::path::Path, - _rel_dirs: &[&str], - _skills: &[aionui_conversation::skill_resolver::ResolvedAgentSkill], - ) -> usize { - 0 - } } let stub_conv_repo = Arc::new(StubConvRepo::new(pool.clone())); @@ -1043,15 +1034,6 @@ async fn setup_with_assistant_repos() -> ( ) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &std::path::Path, - _rel_dirs: &[&str], - _skills: &[aionui_conversation::skill_resolver::ResolvedAgentSkill], - ) -> usize { - 0 - } } let stub_conv_repo = Arc::new(StubConvRepo::new(pool.clone())); @@ -2806,6 +2788,7 @@ async fn create_for_conversation_helper_uses_codex_canonical_full_auto_mode_from args: codex.args.as_deref(), env: codex.env.as_deref(), native_skills_dirs: codex.native_skills_dirs.as_deref(), + skill_delivery: None, behavior_policy: codex.behavior_policy.as_deref(), yolo_id: None, agent_capabilities: codex.agent_capabilities.as_deref(), diff --git a/crates/aionui-db/migrations/043_agent_skill_delivery.sql b/crates/aionui-db/migrations/043_agent_skill_delivery.sql new file mode 100644 index 000000000..72bccc76a --- /dev/null +++ b/crates/aionui-db/migrations/043_agent_skill_delivery.sql @@ -0,0 +1,127 @@ +-- Per-vendor skill delivery declaration. +-- +-- Deliberately NO CHECK constraint. Unlike `status IN (...)` style columns +-- (closed state machines, where a new value SHOULD be a conscious schema +-- change), this column EXISTS so a new vendor capability can ship as data. +-- A CHECK would (1) turn "add a mode" back into "write a migration", and +-- (2) hard-fail a registry insert carrying a newer mode on an older DB -- +-- converting a degradable problem into an outage. Value validation lives in +-- the application layer and is deliberately tolerant +-- (`aionui-api-types/src/skill_delivery.rs`): an unknown mode warns with the +-- actual value and falls back to `injected`. +-- +-- Rows are addressed by `backend`, the label the runtime keys on, rather than +-- by seed id (spread across 001 / 003 / 034). +-- +-- Modes: +-- argv launch-argument delivery (the CLI reads the view directory) +-- protocol protocol-request delivery +-- injected prompt injection + dual channel; the safe default, and what a +-- NULL column reads as +ALTER TABLE agent_metadata ADD COLUMN skill_delivery TEXT; + +-- Layer 1 (argv), claude. Verified against claude 2.1.231: +-- * `claude --help` documents `--plugin-dir ` as repeatable +-- ("--plugin-dir A --plugin-dir B.zip") and `--add-dir `. +-- * `claude --plugin-dir plugin details` accepted the +-- `.claude-plugin/plugin.json` + `skills/{name}/SKILL.md` layout and +-- reported `Skills (1)`, including when the skill directory was a symlink. +-- * REPEATED `--add-dir` was probed as a matched pair under AionUi's actual +-- default permission mode (`default` -- `claude_conn.rs` falls back to it +-- when `config.mode` is empty, and `--allow-dangerously-skip-permissions` +-- only makes bypass REACHABLE, it does not change the initial mode): +-- - with two `--add-dir` flags, both out-of-cwd files were read; +-- - with none, both were refused as "outside the allowed working +-- directories". +-- The pair is what attributes success to the flag rather than to a lax mode. +-- * Registering a skill through `--plugin-dir` does NOT exempt it from that +-- path check, which is why layer 1 still needs `allow_dir_args`. +-- +-- Allow-listing targets the REAL source dirs rather than the view, because a +-- CLI that resolves symlinks to their canonical path would not match a +-- view-directory entry. +UPDATE agent_metadata SET + skill_delivery = '{"mode":"argv","args":["--plugin-dir","{skill_view_dir}"],"allow_dir_args":["--add-dir","{skill_dir}"]}', + updated_at = CAST(strftime('%s','now') AS INTEGER) * 1000 +WHERE backend = 'claude'; + +-- codebuddy stays on layer 2 for now, deliberately. +-- +-- What IS verified, against the version we actually spawn (`npx --package +-- @tencent-ai/codebuddy-code@2.138.0`, pinned in `registry_npx_lock.rs`; the +-- copy on a developer's PATH may be far older and lacks these flags): +-- * `--help` documents `--plugin-dir ` ("Load plugins from local +-- directories (for development/testing)") and `--add-dir `. +-- * argv PARSING accepts `--add-dir A --add-dir B --plugin-dir C` -- the run +-- proceeded to "Authentication required", i.e. it failed on auth, not flags. +-- +-- What is NOT verified: whether `--plugin-dir` actually makes the skills +-- discoverable, which needs an authenticated account we do not have here. +-- Declaring `argv` on that basis would be a real regression risk: `argv` mode +-- also switches injection to LIGHT, so if the flag were inert codebuddy would +-- lose skills entirely. `injected` keeps it working through the dual channel, +-- and promoting it after a live probe is a ONE-ROW data change with no code +-- change and no release -- which is the whole reason this column exists. +UPDATE agent_metadata SET + skill_delivery = '{"mode":"injected","allow_dir_args":["--add-dir","{skill_dir}"]}', + updated_at = CAST(strftime('%s','now') AS INTEGER) * 1000 +WHERE backend = 'codebuddy'; + +-- Layer 1 (protocol), codex. `extraRoots` expects the SKILLS ROOT (directly +-- holding `{name}/SKILL.md`), not a plugin tree (verified: codex-cli 0.146.0 +-- self-generated schema `v2/SkillsExtraRootsSetParams.json`; a live +-- `codex app-server --stdio` probe answered `{"result":{}}` and then pushed a +-- `skills/changed` notification, reporting the skill with `scope: "user"` and +-- the symlink resolved to its real source path). +-- +-- That `scope: "user"` is a HARD CONSTRAINT on the consumer side: the request +-- is process-scoped, so codex layer-1 delivery is mutually exclusive with +-- thread multiplexing. See the enforcing comment in `codex_conn.rs`. +-- +-- No `allow_dir_args`, and that is measured rather than assumed. A full +-- app-server turn probe (initialize -> extraRoots/set -> thread/start -> +-- turn/start) had the agent read a SYMLINKED skill's `references/` file living +-- outside the thread cwd, under `sandbox: workspace-write`, and it succeeded -- +-- reaching the real source path. So codex needs no directory allow-listing for +-- reads, unlike claude. +-- +-- Caveat worth knowing: that probe ran with `approvalPolicy: "never"`. Under +-- the `on-request` default an out-of-cwd read may surface an approval request +-- instead of being refused outright, which is the same "the user confirms" +-- behaviour claude has and which AionUi already renders a permission card for. +UPDATE agent_metadata SET + skill_delivery = '{"mode":"protocol","method":"skills/extraRoots/set"}', + updated_at = CAST(strftime('%s','now') AS INTEGER) * 1000 +WHERE backend = 'codex'; + +-- Layer 2. No session-scoped skill injection parameter exists for these +-- (verified against agy 1.1.13: `agy --help` offers no plugin/skill root flag, +-- and `agy plugin --help` is install/uninstall/enable/disable -- a persistent +-- install surface, not a session one; `opencode debug config` shows no external +-- skill root among its resolved top-level keys). +-- +-- agy gets NO allow-listing, measured rather than assumed. It does take a +-- repeatable `--add-dir`, but our argv passes `--dangerously-skip-permissions` +-- unconditionally (`antigravity/argv.rs`), which puts the session in +-- `permission_mode: "always-proceed"`. A live probe under exactly that argv read +-- a file well outside the cwd with the containing directory NOT allow-listed, so +-- the flag would add one argument per skill for no effect. +-- +-- Worse than useless: a config that lists allow_dir_args reads as "agy is +-- allow-listed", which is a claim this measurement contradicts. +-- +-- If agy ever gains a real permission mode we stop bypassing, allow-listing +-- becomes necessary and this is a one-row change. +UPDATE agent_metadata SET + skill_delivery = '{"mode":"injected","allow_dir_args":[]}', + updated_at = CAST(strftime('%s','now') AS INTEGER) * 1000 +WHERE backend = 'antigravity'; + +UPDATE agent_metadata SET + skill_delivery = '{"mode":"injected"}', + updated_at = CAST(strftime('%s','now') AS INTEGER) * 1000 +WHERE backend = 'opencode'; + +-- Every other row stays NULL, which the application reads as `injected`. That +-- is the point: an unprobed vendor is zero-intrusion by default and needs no +-- migration to work. diff --git a/crates/aionui-db/src/models/agent_metadata.rs b/crates/aionui-db/src/models/agent_metadata.rs index 8183fb881..5f5a32048 100644 --- a/crates/aionui-db/src/models/agent_metadata.rs +++ b/crates/aionui-db/src/models/agent_metadata.rs @@ -1,9 +1,9 @@ //! Row models and parameter structs for the `agent_metadata` table. //! //! JSON-encoded columns (`agent_source_info`, `args`, `env`, -//! `native_skills_dirs`, `behavior_policy`, plus the ACP handshake -//! snapshots) stay as opaque strings at this layer. The ai-agent crate -//! owns the schema of these payloads and decodes them on read. +//! `native_skills_dirs`, `skill_delivery`, `behavior_policy`, plus the ACP +//! handshake snapshots) stay as opaque strings at this layer. The ai-agent +//! crate owns the schema of these payloads and decodes them on read. use aionui_common::TimestampMs; use serde::{Deserialize, Serialize}; @@ -30,6 +30,12 @@ pub struct AgentMetadataRow { pub args: Option, pub env: Option, pub native_skills_dirs: Option, + /// Per-vendor skill delivery declaration (JSON). `None` is read as + /// `{"mode":"injected"}` -- the safe default, so an unprobed vendor works + /// with no migration. Opaque at this layer; `aionui-api-types` owns the + /// schema and parses it tolerantly (an unknown mode warns and falls back + /// rather than failing the row). + pub skill_delivery: Option, pub behavior_policy: Option, /// Native mode id that AionUi's legacy `yolo` / `yoloNoSandbox` @@ -94,6 +100,7 @@ pub struct UpsertAgentMetadataParams<'a> { pub args: Option<&'a str>, pub env: Option<&'a str>, pub native_skills_dirs: Option<&'a str>, + pub skill_delivery: Option<&'a str>, pub behavior_policy: Option<&'a str>, pub yolo_id: Option<&'a str>, pub agent_capabilities: Option<&'a str>, diff --git a/crates/aionui-db/src/repository/conversation.rs b/crates/aionui-db/src/repository/conversation.rs index 6f026bb30..846e22b86 100644 --- a/crates/aionui-db/src/repository/conversation.rs +++ b/crates/aionui-db/src/repository/conversation.rs @@ -93,6 +93,21 @@ pub trait IConversationRepository: Send + Sync { /// The conversation identified by `conversation_id` is excluded. async fn list_associated(&self, user_id: &str, conversation_id: &str) -> Result, DbError>; + /// Every live `(user_id, id)` pair, across all users. + /// + /// Ids only, deliberately: the one caller is the startup sweep that reaps + /// per-conversation skill view directories whose conversation is gone, and + /// loading full rows for that would read every `extra` blob on the + /// installation to answer a question about directory names. + /// + /// Defaults to an empty list so the many repository stubs in this workspace + /// need no body. An empty answer makes the sweep reap nothing, which is the + /// safe direction: a leaked view costs disk, a wrongly-deleted one costs a + /// session its skills. + async fn list_all_conversation_ids(&self) -> Result, DbError> { + Ok(Vec::new()) + } + /// Returns the persisted assistant snapshot for a conversation, if any. async fn get_assistant_snapshot( &self, diff --git a/crates/aionui-db/src/repository/sqlite_agent_metadata.rs b/crates/aionui-db/src/repository/sqlite_agent_metadata.rs index c9d5ba806..a1f4715e0 100644 --- a/crates/aionui-db/src/repository/sqlite_agent_metadata.rs +++ b/crates/aionui-db/src/repository/sqlite_agent_metadata.rs @@ -26,7 +26,7 @@ const DEFAULT_USER_ID: &str = "system_default_user"; const AGENT_METADATA_SAFE_COLUMNS: &str = "\ am.agent_id AS id, am.user_id, am.icon, am.name, am.name_i18n, am.description, am.description_i18n, \ am.backend, am.agent_type, am.agent_source, am.agent_source_info, \ - am.enabled, am.command, am.args, am.env, am.native_skills_dirs, \ + am.enabled, am.command, am.args, am.env, am.native_skills_dirs, am.skill_delivery, \ am.behavior_policy, am.yolo_id, \ CAST(am.agent_capabilities AS BLOB) AS agent_capabilities, \ CAST(am.auth_methods AS BLOB) AS auth_methods, \ @@ -84,6 +84,7 @@ struct AgentMetadataSafeRow { args: Option, env: Option, native_skills_dirs: Option, + skill_delivery: Option, behavior_policy: Option, yolo_id: Option, agent_capabilities: Option>, @@ -127,6 +128,7 @@ impl AgentMetadataSafeRow { args: row.try_get("args")?, env: row.try_get("env")?, native_skills_dirs: row.try_get("native_skills_dirs")?, + skill_delivery: row.try_get("skill_delivery")?, behavior_policy: row.try_get("behavior_policy")?, yolo_id: row.try_get("yolo_id")?, agent_capabilities: row.try_get("agent_capabilities")?, @@ -203,6 +205,7 @@ impl AgentMetadataSafeRow { args: self.args, env: self.env, native_skills_dirs: self.native_skills_dirs, + skill_delivery: self.skill_delivery, behavior_policy: self.behavior_policy, yolo_id: self.yolo_id, agent_capabilities, @@ -684,12 +687,12 @@ impl SqliteAgentMetadataRepository { "INSERT INTO agent_metadata \ (id, agent_id, user_id, icon, name, name_i18n, description, description_i18n, \ backend, agent_type, agent_source, agent_source_info, \ - enabled, command, args, env, native_skills_dirs, \ + enabled, command, args, env, native_skills_dirs, skill_delivery, \ behavior_policy, yolo_id, \ agent_capabilities, auth_methods, config_options, \ available_modes, available_models, available_commands, \ sort_order, created_at, updated_at) \ - VALUES (lower(hex(randomblob(16))), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + VALUES (lower(hex(randomblob(16))), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ {conflict_target} \ icon = excluded.icon, \ name = excluded.name, \ @@ -705,6 +708,7 @@ impl SqliteAgentMetadataRepository { args = excluded.args, \ env = excluded.env, \ native_skills_dirs = excluded.native_skills_dirs, \ + skill_delivery = excluded.skill_delivery, \ behavior_policy = excluded.behavior_policy, \ yolo_id = excluded.yolo_id, \ agent_capabilities = excluded.agent_capabilities, \ @@ -735,6 +739,7 @@ impl SqliteAgentMetadataRepository { .bind(params.args) .bind(params.env) .bind(params.native_skills_dirs) + .bind(params.skill_delivery) .bind(params.behavior_policy) .bind(params.yolo_id) .bind(params.agent_capabilities) @@ -840,6 +845,7 @@ mod tests { args: Some("[]"), env: Some("[]"), native_skills_dirs: Some(r#"[".claude/skills"]"#), + skill_delivery: None, behavior_policy: Some(r#"{"supports_side_question":true}"#), yolo_id: Some("bypassPermissions"), agent_capabilities: None, @@ -1165,6 +1171,7 @@ mod tests { args: None, env: None, native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None, diff --git a/crates/aionui-db/src/repository/sqlite_conversation.rs b/crates/aionui-db/src/repository/sqlite_conversation.rs index 8c9db04ab..e5136493f 100644 --- a/crates/aionui-db/src/repository/sqlite_conversation.rs +++ b/crates/aionui-db/src/repository/sqlite_conversation.rs @@ -609,6 +609,13 @@ impl IConversationRepository for SqliteConversationRepository { Ok(rows) } + async fn list_all_conversation_ids(&self) -> Result, DbError> { + let rows: Vec<(String, String)> = sqlx::query_as("SELECT user_id, id FROM conversations") + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + async fn list_associated(&self, user_id: &str, conversation_id: &str) -> Result, DbError> { // First get the target conversation's workspace let target = sqlx::query_as::<_, ConversationRow>("SELECT * FROM conversations WHERE id = ? AND user_id = ?") @@ -2235,6 +2242,38 @@ mod tests { assert_eq!(result.len(), 2); } + /// Feeds the startup sweep that reaps orphan skill view directories, so it + /// must span USERS: scoping it to one user would make every other user's + /// conversation look dead and delete their views. + #[tokio::test] + async fn list_all_conversation_ids_spans_users() { + let (repo, db) = setup().await; + sqlx::query( + "INSERT INTO users (id, user_type, username, password_hash, status, session_generation, created_at, updated_at) \ + VALUES ('user_b', 'local', 'user_b', 'hash', 'active', 0, 1, 1)", + ) + .execute(db.pool()) + .await + .unwrap(); + + let mut a = sample_conversation(SYSTEM_USER_ID); + a.id = "conv_a".into(); + repo.create(&a).await.unwrap(); + let mut b = sample_conversation("user_b"); + b.id = "conv_b".into(); + repo.create(&b).await.unwrap(); + + let mut ids = repo.list_all_conversation_ids().await.unwrap(); + ids.sort(); + assert_eq!( + ids, + vec![ + (SYSTEM_USER_ID.to_owned(), "conv_a".to_owned()), + ("user_b".to_owned(), "conv_b".to_owned()), + ] + ); + } + #[tokio::test] async fn list_associated_by_workspace() { let (repo, _db) = setup().await; diff --git a/crates/aionui-db/tests/agent_binding_resolver.rs b/crates/aionui-db/tests/agent_binding_resolver.rs index bb93b82ed..9bd8f5462 100644 --- a/crates/aionui-db/tests/agent_binding_resolver.rs +++ b/crates/aionui-db/tests/agent_binding_resolver.rs @@ -106,6 +106,7 @@ fn custom_agent_params<'a>(id: &'a str, name: &'a str, backend: &'a str) -> Upse args: Some("[]"), env: Some("[]"), native_skills_dirs: Some("[]"), + skill_delivery: None, behavior_policy: Some("{}"), yolo_id: None, agent_capabilities: None, diff --git a/crates/aionui-db/tests/agent_skill_delivery_migration.rs b/crates/aionui-db/tests/agent_skill_delivery_migration.rs new file mode 100644 index 000000000..09f11ba9b --- /dev/null +++ b/crates/aionui-db/tests/agent_skill_delivery_migration.rs @@ -0,0 +1,128 @@ +//! Migration 043 adds `agent_metadata.skill_delivery` and seeds per-vendor values. +//! +//! Assertions are on the RAW JSON, deliberately. At this layer the column is an +//! opaque string (see `models/agent_metadata.rs`) — `aionui-api-types` owns the +//! schema and its tolerant parser is unit-tested there. Keeping this test +//! serde_json-only avoids coupling the data layer to the DTO layer. +//! +//! Rows are addressed by `backend`, not by seed id: the id table is spread +//! across 001 / 003 / 034, and `backend` is the label the runtime keys on. + +use aionui_db::init_database_memory; + +async fn migrated_pool() -> sqlx::SqlitePool { + // The crate's own initializer, so the test exercises the same migration path + // production does (a bare Migrator run misses its setup). + let db = init_database_memory().await.expect("in-memory database"); + db.pool().clone() +} + +async fn delivery_json(pool: &sqlx::SqlitePool, backend: &str) -> serde_json::Value { + let raw: Option = sqlx::query_scalar("SELECT skill_delivery FROM agent_metadata WHERE backend = ? LIMIT 1") + .bind(backend) + .fetch_one(pool) + .await + .unwrap_or_else(|e| panic!("the {backend} row must exist after 001/003/034: {e}")); + let raw = raw.unwrap_or_else(|| panic!("{backend} must carry a seeded skill_delivery")); + serde_json::from_str(&raw).unwrap_or_else(|e| panic!("{backend} skill_delivery must be valid JSON: {e}")) +} + +#[tokio::test] +async fn claude_gets_layer_one_argv_delivery_with_allow_dir_args() { + let pool = migrated_pool().await; + let delivery = delivery_json(&pool, "claude").await; + + assert_eq!(delivery["mode"], "argv"); + assert_eq!( + delivery["args"], + serde_json::json!(["--plugin-dir", "{skill_view_dir}"]) + ); + // Not optional: spec §10.2 #9 measured that a `--plugin-dir` registered + // skill still fails claude's path check when the agent Reads its + // supplementary files, under AionUi's real default permission mode. + assert_eq!( + delivery["allow_dir_args"], + serde_json::json!(["--add-dir", "{skill_dir}"]) + ); +} + +/// codebuddy is deliberately NOT on layer 1 yet. Its pinned build (2.138.0) +/// documents `--plugin-dir` and accepts it at the argv level, but whether the +/// flag actually makes skills discoverable is unprobed (it needs an +/// authenticated account). Declaring `argv` on that basis would be a real +/// regression: `argv` also switches injection to LIGHT, so an inert flag would +/// leave codebuddy with no skills at all. This test pins the conservative +/// choice so a future promotion is a conscious edit. +#[tokio::test] +async fn codebuddy_stays_injected_until_its_layer_one_behavior_is_probed() { + let pool = migrated_pool().await; + let delivery = delivery_json(&pool, "codebuddy").await; + + assert_eq!(delivery["mode"], "injected"); + assert_eq!( + delivery["allow_dir_args"], + serde_json::json!(["--add-dir", "{skill_dir}"]) + ); +} + +#[tokio::test] +async fn codex_gets_protocol_delivery() { + let pool = migrated_pool().await; + let delivery = delivery_json(&pool, "codex").await; + + assert_eq!(delivery["mode"], "protocol"); + // Verified: codex-cli 0.146.0 self-generated schema + // `v2/SkillsExtraRootsSetParams.json`. + assert_eq!(delivery["method"], "skills/extraRoots/set"); +} + +/// agy gets no allow-listing, and that is a MEASURED result rather than an +/// omission: our argv always passes `--dangerously-skip-permissions`, and a live +/// probe under exactly that argv read a file outside the cwd with its directory +/// not allow-listed. Declaring the flag anyway would add one argument per skill +/// for no effect, and would read as a guarantee the measurement contradicts. +#[tokio::test] +async fn antigravity_is_injected_with_no_allow_listing() { + let pool = migrated_pool().await; + let delivery = delivery_json(&pool, "antigravity").await; + + assert_eq!(delivery["mode"], "injected"); + assert_eq!(delivery["allow_dir_args"], serde_json::json!([])); +} + +#[tokio::test] +async fn opencode_is_injected() { + let pool = migrated_pool().await; + assert_eq!(delivery_json(&pool, "opencode").await["mode"], "injected"); +} + +/// The column must stay nullable with NO CHECK: it is an open extension point. +/// A CHECK would turn "ship a new mode as data" back into "write a migration", +/// and would hard-fail a registry insert carrying a newer mode on an older DB — +/// converting a degradable problem into an outage. +#[tokio::test] +async fn the_db_layer_accepts_an_unknown_mode() { + let pool = migrated_pool().await; + sqlx::query("UPDATE agent_metadata SET skill_delivery = ? WHERE backend = 'opencode'") + .bind(r#"{"mode":"future_mode_v9"}"#) + .execute(&pool) + .await + .expect("the DB must not constrain skill_delivery values"); +} + +/// An unverified vendor must keep the safe NULL default (read as `injected`). +/// Asserted so a later migration cannot quietly opt an unprobed vendor into +/// layer 1 — G4 requires a new vendor to be zero-intrusion by default. +#[tokio::test] +async fn unverified_vendors_stay_null() { + let pool = migrated_pool().await; + let null_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM agent_metadata \ + WHERE skill_delivery IS NULL \ + AND backend NOT IN ('claude','codex','codebuddy','antigravity','opencode')", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(null_count > 0, "unverified vendors must keep the safe NULL default"); +} diff --git a/crates/aionui-extension/src/lib.rs b/crates/aionui-extension/src/lib.rs index 1544fc208..c6ef4f277 100644 --- a/crates/aionui-extension/src/lib.rs +++ b/crates/aionui-extension/src/lib.rs @@ -21,6 +21,7 @@ pub mod resolvers; pub mod routes; pub mod skill_routes; pub mod skill_service; +pub mod skill_view; pub mod startup_materialize; pub mod state; pub mod template; @@ -55,11 +56,11 @@ pub use skill_service::{ BUILTIN_SKILLS_ENV_VAR, ExternalSkillSource, NamedPath, ResolvedAgentSkill, ScannedSkill, SkillListItem, SkillPaths, SkillSource, builtin_skills_corpus, builtin_skills_materialize_marker, delete_skill, delete_skill_with_repo, detect_and_count_external_skills, detect_common_skill_paths, export_skill_with_symlink, - get_skill_paths, import_skill, import_skills_with_repo, link_workspace_skills, list_available_skills, - list_available_skills_with_repo, list_available_skills_with_repo_for_user, materialize_skills_for_agent, - materialize_skills_for_agent_with_repo, materialize_skills_for_agent_with_repo_for_user, read_builtin_rule, - read_builtin_skill, read_skill_info, resolve_skill_paths, scan_for_skills, sync_builtin_skill_catalog_into_repo, - sync_skill_catalog_into_repo, sync_skill_catalog_into_repo_for_user, + get_skill_paths, import_skill, import_skills_with_repo, list_available_skills, list_available_skills_with_repo, + list_available_skills_with_repo_for_user, materialize_skills_for_agent, materialize_skills_for_agent_with_repo, + materialize_skills_for_agent_with_repo_for_user, read_builtin_rule, read_builtin_skill, read_skill_info, + resolve_skill_paths, scan_for_skills, sync_builtin_skill_catalog_into_repo, sync_skill_catalog_into_repo, + sync_skill_catalog_into_repo_for_user, }; pub use skill_service::{ delete_assistant_rule, delete_assistant_skill, read_assistant_rule, read_assistant_skill, write_assistant_rule, diff --git a/crates/aionui-extension/src/skill_service.rs b/crates/aionui-extension/src/skill_service.rs index 43b6e774f..062c04d77 100644 --- a/crates/aionui-extension/src/skill_service.rs +++ b/crates/aionui-extension/src/skill_service.rs @@ -1368,96 +1368,28 @@ pub async fn materialize_skills_for_agent_with_repo_for_user( Ok(resolved) } -/// Create symlinks from a set of resolved skills into the agent CLI's -/// native skills directories inside `workspace`. +/// Strip a `SKILL.md`'s YAML frontmatter, returning just the body. /// -/// For each relative `skills_rel_dir` (e.g. `.claude/skills`): -/// 1. Resolve the target directory. Existing `{workspace}/{skills_rel_dir}/` -/// wins; if the requested leaf is `skills` and sibling `skill` already -/// exists, reuse that singular directory; otherwise create the requested -/// directory. -/// 2. For each `{ name, source_path }` in `skills`, create a symlink -/// `{target_skills_dir}/{name} -> {source_path}`. +/// Lives here rather than in a consumer because BOTH skill-delivery channels +/// hand a body to the agent -- the `[LOAD_SKILL]` text protocol and the +/// `aioncore skills show` command -- and the two must return identical content. +/// A second copy is how they would quietly drift apart. /// -/// Existing symlinks/files at the target name are left untouched -/// (first-write-wins, matches the frontend's lstat-then-skip behavior -/// before symlink creation). Individual symlink failures are logged and -/// skipped — skill discovery degrades gracefully, it is not fatal. -/// -/// Returns the number of symlinks successfully created across all -/// target dirs. -pub async fn link_workspace_skills( - workspace: &Path, - skills_rel_dirs: &[&str], - skills: &[ResolvedAgentSkill], -) -> Result { - let mut created = 0usize; - for rel in skills_rel_dirs { - let target_skills_dir = resolve_workspace_skills_dir(workspace, rel).await; - tokio::fs::create_dir_all(&target_skills_dir).await?; - - for skill in skills { - let target = target_skills_dir.join(&skill.name); - match tokio::fs::symlink_metadata(&target).await { - // Target already exists — leave it alone. - Ok(_) => continue, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} - Err(e) => { - warn!( - target = %target.display(), - error = %e, - "skipping skill link: failed to stat target" - ); - continue; - } - } - match link_skill_or_fallback_copy(&skill.source_path, &target).await { - Ok(()) => { - debug!( - skill = %skill.name, - target = %target.display(), - "linked workspace skill" - ); - created += 1; - } - Err(e) => { - warn!( - skill = %skill.name, - target = %target.display(), - error = %e, - "failed to link workspace skill" - ); - } - } - } +/// Content without a leading `---`, or with an unterminated block, is returned +/// unchanged: a malformed skill should still deliver something readable. +pub fn extract_skill_body(content: &str) -> String { + let trimmed = content.trim_start(); + if !trimmed.starts_with("---") { + return content.to_string(); + } + + let after_open = &trimmed[3..]; + if let Some(close_idx) = after_open.find("---") { + let after_close = &after_open[close_idx + 3..]; + after_close.trim_start_matches('\n').to_string() + } else { + content.to_string() } - Ok(created) -} - -async fn resolve_workspace_skills_dir(workspace: &Path, skills_rel_dir: &str) -> PathBuf { - let requested = workspace.join(skills_rel_dir); - if path_is_dir(&requested).await { - return requested; - } - - let rel_path = Path::new(skills_rel_dir); - if rel_path.file_name() == Some(std::ffi::OsStr::new("skills")) - && let Some(parent) = rel_path.parent() - { - let singular = workspace.join(parent).join("skill"); - if path_is_dir(&singular).await { - return singular; - } - } - - requested -} - -async fn path_is_dir(path: &Path) -> bool { - tokio::fs::metadata(path) - .await - .map(|metadata| metadata.is_dir()) - .unwrap_or(false) } /// Resolve a skill name to its on-disk source directory using the same @@ -2126,7 +2058,13 @@ fn zip_error(err: zip::result::ZipError) -> ExtensionError { /// --- /// Body content here... /// ``` -fn parse_frontmatter_fields(content: &str) -> Option<(String, String)> { +/// Parse a `SKILL.md`'s `name` + `description` frontmatter fields. +/// +/// Public so the runtime skills domain can build its listing from the SAME +/// on-disk source `show` reads, rather than from the DB catalog. That keeps the +/// two commands consistent by construction and independent of whether a startup +/// catalog sync has run yet. +pub fn parse_frontmatter_fields(content: &str) -> Option<(String, String)> { #[derive(serde::Deserialize)] struct SkillFrontmatter { #[serde(default)] @@ -2179,126 +2117,14 @@ fn extract_frontmatter_text(content: &str) -> Option<&str> { } /// Recursively copy a directory. -async fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), ExtensionError> { - tokio::fs::create_dir_all(dst).await?; - - let mut entries = tokio::fs::read_dir(src).await?; - while let Ok(Some(entry)) = entries.next_entry().await { - let entry_path = entry.path(); - let dest_path = dst.join(entry.file_name()); - - if entry_path.is_dir() { - Box::pin(copy_dir_recursive(&entry_path, &dest_path)).await?; - } else { - tokio::fs::copy(&entry_path, &dest_path).await?; - } - } - - Ok(()) -} - -/// Try to symlink `src` into `dst`; on failure, fall back to a recursive -/// copy of the source directory. -/// -/// Motivation: on Windows machines without "Developer Mode" or admin -/// privileges, `CreateSymbolicLinkW` fails with `os error 1314` -/// (`ERROR_PRIVILEGE_NOT_HELD`). Auto-injected builtin skills under each -/// backend's `./skills/` directory then become invisible to the -/// CLI agent — silently degrading the product. Falling back to a copy -/// keeps the skills discoverable; the trade-off is that copies do not -/// track upstream changes until the next link pass clears them. The -/// fallback applies on every platform (Linux/macOS shouldn't normally -/// hit this, but we keep behavior uniform so a future EPERM/EROFS sandbox -/// also stays healthy). -/// -/// Logs a `warn!` with the OS error kind and `raw_os_error` so we can -/// keep tracking 1314 vs other failure modes in telemetry. No -/// user-identifying data is logged — only the source/target paths -/// (already considered safe to log elsewhere in this module) and the -/// error code. -async fn link_skill_or_fallback_copy(src: &Path, dst: &Path) -> Result<(), ExtensionError> { - match create_symlink_for_link(src, dst).await { - Ok(()) => Ok(()), - Err(e) => { - // Surface the raw OS error so dashboards can keep counting 1314 - // (ERROR_PRIVILEGE_NOT_HELD) separately from other failure modes. - let raw_os_error = match &e { - ExtensionError::Io(io_err) => io_err.raw_os_error(), - _ => None, - }; - warn!( - src = %src.display(), - dst = %dst.display(), - error = %e, - raw_os_error = ?raw_os_error, - "create_symlink failed; falling back to copy_dir_recursive" - ); - copy_dir_recursive(src, dst).await - } - } -} - -/// Wrapper around [`create_symlink`] that allows tests to inject a -/// synthetic failure. In non-test builds this is a thin call-through to -/// the platform-specific [`create_symlink`] below. -async fn create_symlink_for_link(src: &Path, dst: &Path) -> Result<(), ExtensionError> { - #[cfg(test)] - { - if test_overrides::should_force_symlink_failure() { - // Use PermissionDenied to mimic the shape Windows returns - // for ERROR_PRIVILEGE_NOT_HELD. The exact raw_os_error is - // platform-specific so we only assert on kind in tests. - return Err(ExtensionError::Io(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "forced symlink failure (test)", - ))); - } - } - create_symlink(src, dst).await -} - -/// Test-only knob to force the symlink primitive to fail, exercising -/// the [`copy_dir_recursive`] fallback branch on platforms where -/// symlinking would otherwise succeed (Linux/macOS CI). -#[cfg(test)] -mod test_overrides { - use std::sync::atomic::{AtomicBool, Ordering}; - - static FORCE_SYMLINK_FAILURE: AtomicBool = AtomicBool::new(false); - - pub fn should_force_symlink_failure() -> bool { - FORCE_SYMLINK_FAILURE.load(Ordering::SeqCst) - } - - /// RAII guard that flips `FORCE_SYMLINK_FAILURE` on creation and - /// resets it on drop. Tests using this guard must be marked - /// `#[serial_test::serial]` if any other test in the binary also - /// flips the flag — at present only one test uses it, so a guard - /// is enough. - pub struct ForceFailureGuard; - - impl ForceFailureGuard { - pub fn new() -> Self { - FORCE_SYMLINK_FAILURE.store(true, Ordering::SeqCst); - Self - } - } - - impl Drop for ForceFailureGuard { - fn drop(&mut self) { - FORCE_SYMLINK_FAILURE.store(false, Ordering::SeqCst); - } - } -} - /// Create a symlink (platform-aware). #[cfg(unix)] -async fn create_symlink(src: &Path, dst: &Path) -> Result<(), ExtensionError> { +pub(crate) async fn create_symlink(src: &Path, dst: &Path) -> Result<(), ExtensionError> { tokio::fs::symlink(src, dst).await.map_err(ExtensionError::Io) } #[cfg(windows)] -async fn create_symlink(src: &Path, dst: &Path) -> Result<(), ExtensionError> { +pub(crate) async fn create_symlink(src: &Path, dst: &Path) -> Result<(), ExtensionError> { // On Windows, directory symlinks require `SeCreateSymbolicLink` // (Developer Mode or Admin), which most users don't have — this is // the source of the Sentry I1 family of `os error 1314` failures. @@ -3348,20 +3174,6 @@ mod tests { .any(|entry| entry.file_name().to_string_lossy().starts_with(IMPORT_STAGING_PREFIX)) } - fn create_resolved_test_skill(source_root: &Path, name: &str) -> ResolvedAgentSkill { - let source_path = source_root.join(name); - std::fs::create_dir_all(&source_path).unwrap(); - std::fs::write( - source_path.join(SKILL_MANIFEST_FILE), - format!("---\nname: {name}\ndescription: test\n---\nbody"), - ) - .unwrap(); - ResolvedAgentSkill { - name: name.to_owned(), - source_path, - } - } - fn write_test_zip(path: &Path, entries: &[(&str, &str)]) { let file = std::fs::File::create(path).unwrap(); let mut zip = zip::ZipWriter::new(file); @@ -3708,187 +3520,4 @@ mod tests { assert!(!paths.data_dir.join("agent-skills").exists()); assert!(!paths.data_dir.join("conversations").exists()); } - - // ----------------------------------------------------------------------- - // Windows symlink → copy_dir_recursive fallback - // ----------------------------------------------------------------------- - - /// When the platform symlink primitive fails (mirrors Windows - /// `os error 1314 ERROR_PRIVILEGE_NOT_HELD`), `link_workspace_skills` - /// must materialize the skill via `copy_dir_recursive` instead so the - /// CLI agent can still discover it. Forced via `ForceFailureGuard` - /// on Linux/macOS CI where symlinking would otherwise succeed. - #[tokio::test] - async fn link_workspace_skills_falls_back_to_copy_when_symlink_fails() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("workspace"); - let source_root = tmp.path().join("sources"); - - // Seed a fake skill source directory with a SKILL.md and a - // nested file so we can verify the copy is recursive. - let skill_source = source_root.join("my-skill"); - std::fs::create_dir_all(skill_source.join("nested")).unwrap(); - std::fs::write( - skill_source.join(SKILL_MANIFEST_FILE), - "---\nname: my-skill\ndescription: test\n---\nbody", - ) - .unwrap(); - std::fs::write(skill_source.join("nested").join("data.txt"), "payload").unwrap(); - - let resolved = vec![ResolvedAgentSkill { - name: "my-skill".to_owned(), - source_path: skill_source.clone(), - }]; - - // Force the symlink primitive to fail for the duration of this - // test, exercising the copy fallback branch. - let _guard = test_overrides::ForceFailureGuard::new(); - - let created = link_workspace_skills(&workspace, &[".claude/skills"], &resolved) - .await - .expect("link_workspace_skills should succeed via copy fallback"); - assert_eq!(created, 1, "exactly one skill should be materialized"); - - let target = workspace.join(".claude/skills").join("my-skill"); - assert!(target.exists(), "target directory must exist"); - // It must NOT be a symlink — fallback path uses copy_dir_recursive. - let meta = tokio::fs::symlink_metadata(&target).await.unwrap(); - assert!( - !meta.file_type().is_symlink(), - "fallback must produce a real directory, not a symlink" - ); - assert!(target.is_dir(), "target must be a directory"); - - // Verify the contents were copied recursively. - let manifest = std::fs::read_to_string(target.join(SKILL_MANIFEST_FILE)).unwrap(); - assert!(manifest.contains("name: my-skill")); - let nested = std::fs::read_to_string(target.join("nested").join("data.txt")).unwrap(); - assert_eq!(nested, "payload"); - } - - #[tokio::test] - async fn link_workspace_skills_uses_existing_singular_skill_dir() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("workspace"); - let source_root = tmp.path().join("sources"); - let existing_skill_dir = workspace.join(".claude").join("skill"); - std::fs::create_dir_all(&existing_skill_dir).unwrap(); - - let resolved = vec![create_resolved_test_skill(&source_root, "my-skill")]; - - let created = link_workspace_skills(&workspace, &[".claude/skills"], &resolved) - .await - .expect("link_workspace_skills should use existing singular skill dir"); - assert_eq!(created, 1, "exactly one skill should be materialized"); - - assert!( - existing_skill_dir.join("my-skill").exists(), - "existing singular skill dir should receive the skill" - ); - assert!( - !workspace.join(".claude").join("skills").exists(), - "plural skills dir should not be created when singular skill dir already exists" - ); - } - - #[tokio::test] - async fn link_workspace_skills_creates_requested_dir_inside_existing_agent_dir() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("workspace"); - let source_root = tmp.path().join("sources"); - std::fs::create_dir_all(workspace.join(".codex")).unwrap(); - - let resolved = vec![create_resolved_test_skill(&source_root, "my-skill")]; - - let created = link_workspace_skills(&workspace, &[".codex/skills"], &resolved) - .await - .expect("link_workspace_skills should create missing skills dir"); - assert_eq!(created, 1, "exactly one skill should be materialized"); - - assert!( - workspace.join(".codex/skills/my-skill").is_dir(), - "missing skills dir should be created under the existing agent dir" - ); - } - - #[tokio::test] - async fn link_workspace_skills_prefers_existing_plural_dir_over_singular_sibling() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("workspace"); - let source_root = tmp.path().join("sources"); - let plural_dir = workspace.join(".gemini").join("skills"); - let singular_dir = workspace.join(".gemini").join("skill"); - std::fs::create_dir_all(&plural_dir).unwrap(); - std::fs::create_dir_all(&singular_dir).unwrap(); - - let resolved = vec![create_resolved_test_skill(&source_root, "my-skill")]; - - let created = link_workspace_skills(&workspace, &[".gemini/skills"], &resolved) - .await - .expect("link_workspace_skills should prefer the requested existing dir"); - assert_eq!(created, 1, "exactly one skill should be materialized"); - - assert!( - plural_dir.join("my-skill").is_dir(), - "existing plural skills dir should receive the skill" - ); - assert!( - !singular_dir.join("my-skill").exists(), - "singular sibling should remain untouched when requested dir exists" - ); - } - - /// Windows-only: directory linking must go through an NTFS junction - /// (created by the `junction` crate) rather than `symlink_dir`, so - /// the link works for users without Developer Mode. We assert the - /// resulting path is a reparse point (junction is reported as a - /// symlink by `symlink_metadata().file_type().is_symlink()`) and - /// that the source contents are reachable through the link. - /// - /// The test is skipped on non-Windows platforms. - #[cfg(target_os = "windows")] - #[tokio::test] - async fn link_workspace_skills_uses_junction_on_windows() { - let tmp = TempDir::new().unwrap(); - let workspace = tmp.path().join("workspace"); - let source_root = tmp.path().join("sources"); - - let skill_source = source_root.join("my-skill"); - std::fs::create_dir_all(skill_source.join("nested")).unwrap(); - std::fs::write( - skill_source.join(SKILL_MANIFEST_FILE), - "---\nname: my-skill\ndescription: test\n---\nbody", - ) - .unwrap(); - std::fs::write(skill_source.join("nested").join("data.txt"), "payload").unwrap(); - - let resolved = vec![ResolvedAgentSkill { - name: "my-skill".to_owned(), - source_path: skill_source.clone(), - }]; - - let created = link_workspace_skills(&workspace, &[".claude/skills"], &resolved) - .await - .expect("link_workspace_skills should succeed via junction"); - assert_eq!(created, 1, "exactly one skill should be materialized"); - - let target = workspace.join(".claude/skills").join("my-skill"); - assert!(target.exists(), "target path must exist"); - - // Junctions are reparse points; `symlink_metadata` reports them - // as symlinks on Windows. The directory copy fallback would - // produce a real directory (is_symlink() == false). - let meta = std::fs::symlink_metadata(&target).unwrap(); - assert!( - meta.file_type().is_symlink(), - "Windows directory link must be a junction (reparse point), \ - not a copied directory" - ); - - // Reading through the link must surface the source contents. - let manifest = std::fs::read_to_string(target.join(SKILL_MANIFEST_FILE)).unwrap(); - assert!(manifest.contains("name: my-skill")); - let nested = std::fs::read_to_string(target.join("nested").join("data.txt")).unwrap(); - assert_eq!(nested, "payload"); - } } diff --git a/crates/aionui-extension/src/skill_view.rs b/crates/aionui-extension/src/skill_view.rs new file mode 100644 index 000000000..4ba464a72 --- /dev/null +++ b/crates/aionui-extension/src/skill_view.rs @@ -0,0 +1,657 @@ +//! Per-conversation skill VIEW directory, owned exclusively by AionUi. +//! +//! This is the point of the refactor: the symlink LANDING SITE moves out of the +//! user's workspace (which may be a git repository) and into +//! `{data_dir}/session-skills/{user_id}/{conversation_id}/`. Skill sources, the +//! enable model and the snapshot semantics are unchanged — only where the links +//! land. +//! +//! Layout, one tree satisfying both vendor shapes: +//! +//! ```text +//! {view}/.claude-plugin/plugin.json <- what --plugin-dir needs +//! {view}/skills/{name} -> {real source dir} +//! ``` +//! +//! claude takes `{view}` (the plugin root); codex takes `{view}/skills` (the +//! skills root, directly holding `{name}/SKILL.md`). Both were probed against +//! this exact layout, including with the skill directory as a symlink. +//! +//! Because AionUi OWNS this tree, two things differ from the old workspace path: +//! rebuild is an EXACT match against the snapshot (no first-write-wins, so a +//! skill dropped from the snapshot actually disappears), and deletion is safe. +//! +//! There is deliberately NO copy fallback when linking fails. The old workspace +//! path degraded to a recursive copy of real files, which this refactor removes: +//! a failed link is skipped with a `warn`, because materializing user files into +//! a directory we then treat as disposable is worse than the skill being absent. + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex as BlockingMutex}; + +use tokio::sync::Mutex; +use tracing::{debug, info, warn}; + +use crate::error::ExtensionError; +use crate::skill_service::{ResolvedAgentSkill, create_symlink}; + +const VIEW_ROOT_DIR_NAME: &str = "session-skills"; +const SKILLS_SUBDIR: &str = "skills"; +const PLUGIN_MANIFEST_DIR: &str = ".claude-plugin"; +const PLUGIN_MANIFEST_FILE: &str = "plugin.json"; + +/// The plugin name. +/// +/// This is NOT an internal identifier: a plugin's name becomes the prefix of +/// every skill name the agent sees, so a conversation's `cron` is presented as +/// `aionui:cron` (the same way `superpowers:brainstorming` appears). It is +/// user-visible text. +/// +/// Consequence for callers: `extra.skills` stores the BARE name (`cron`) while +/// the agent side is prefixed. Any logic that matches, counts, or correlates by +/// skill name must not assume the two sides are equal. +pub const PLUGIN_NAME: &str = "aionui"; + +pub fn view_root(data_dir: &Path) -> PathBuf { + data_dir.join(VIEW_ROOT_DIR_NAME) +} + +/// Ids reaching this module come from storage, so a traversal-shaped value is +/// refused here rather than trusted upstream. +fn validate_segment(value: &str, field: &'static str) -> Result<(), ExtensionError> { + if value.is_empty() || value == "." || value.contains('/') || value.contains('\\') || value.contains("..") { + return Err(ExtensionError::PathTraversal(format!( + "{field} is not a safe path segment" + ))); + } + Ok(()) +} + +pub fn view_dir(data_dir: &Path, user_id: &str, conversation_id: &str) -> Result { + validate_segment(user_id, "user_id")?; + validate_segment(conversation_id, "conversation_id")?; + Ok(view_root(data_dir).join(user_id).join(conversation_id)) +} + +/// The SKILLS root (`{view}/skills`), which is what codex's `extraRoots` +/// expects — as opposed to [`view_dir`], the plugin root claude wants. +pub fn view_skills_dir(data_dir: &Path, user_id: &str, conversation_id: &str) -> Result { + Ok(view_dir(data_dir, user_id, conversation_id)?.join(SKILLS_SUBDIR)) +} + +fn plugin_manifest_body() -> String { + serde_json::json!({ + "name": PLUGIN_NAME, + "version": "0.0.1", + "description": "AionUi session skills", + }) + .to_string() +} + +/// Serializes [`rebuild_view`] per conversation. +/// +/// A first turn rebuilds the view THREE times -- conversation create, runtime +/// ensure, and send -- and the last two land under a millisecond apart. Because +/// the rebuild is "snapshot the link names, wipe the tree, relink", two +/// concurrent calls interleave destructively: one call's `remove_dir_all` can +/// land after the other has already created links, so links that were made +/// disappear while the call that made them has moved past those names. Observed +/// live as four `File exists` link failures plus two `skills 2 / requested 4` +/// lines on a view that in fact held all four -- i.e. the reported counts were +/// wrong, and the benign on-disk outcome was timing luck rather than a property +/// of the code. +/// +/// Keyed per (user, conversation) rather than one global lock so unrelated +/// conversations keep rebuilding concurrently. +static VIEW_LOCKS: LazyLock>>>> = + LazyLock::new(|| BlockingMutex::new(HashMap::new())); + +/// The lock guarding one conversation's view. Never held across an await inside +/// this function -- only the returned `Arc` is. +fn view_lock(user_id: &str, conversation_id: &str) -> Arc> { + let key = format!("{user_id}/{conversation_id}"); + // A poisoned registry is not a reason to refuse a rebuild: the map holds no + // invariant beyond "one lock per key", which a panicking holder cannot break. + let mut locks = VIEW_LOCKS.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + // Entries nobody holds are dropped here, so the map cannot grow with every + // conversation the process has ever opened. A lock currently held (or being + // awaited) has a second owner, so it survives. + locks.retain(|_, lock| Arc::strong_count(lock) > 1); + Arc::clone(locks.entry(key).or_default()) +} + +/// Rebuild the view so it matches `skills` EXACTLY. Returns the number of links +/// the view holds afterwards. Idempotent: build-task calls this on every session +/// open, and a call that finds the view already correct changes nothing. +pub async fn rebuild_view( + data_dir: &Path, + user_id: &str, + conversation_id: &str, + skills: &[ResolvedAgentSkill], +) -> Result { + // Validated before the lock is taken, so a traversal-shaped id cannot even + // register a lock entry. + let view = view_dir(data_dir, user_id, conversation_id)?; + let skills_dir = view.join(SKILLS_SUBDIR); + + let lock = view_lock(user_id, conversation_id); + let _serialized = lock.lock().await; + + // Which skills can actually be linked. Resolved BEFORE the tree is touched so + // the desired set is known up front, which is what makes the "already correct" + // check below possible. + let mut linkable = Vec::with_capacity(skills.len()); + for skill in skills { + if tokio::fs::try_exists(&skill.source_path).await.unwrap_or(false) { + linkable.push(skill); + } else { + warn!( + skill = %skill.name, + conversation_id = %conversation_id, + "skill_view: source directory missing; skipping this skill" + ); + } + } + let desired: HashSet = linkable.iter().map(|skill| skill.name.clone()).collect(); + + // Names already linked, captured BEFORE the tree is replaced, so a mid-session + // addition can be reported. Allow-listing (`--add-dir`) is a SPAWN argument: + // a skill added while the agent is running gets into the view and the index, + // but its supplementary files stay unreadable to the already-started process + // until the runtime restarts. Silence here would present as "the agent can + // see the skill but cannot open its references", with nothing to point at. + let previously_linked = existing_link_names(&skills_dir).await; + + tokio::fs::create_dir_all(view.join(PLUGIN_MANIFEST_DIR)).await?; + tokio::fs::write( + view.join(PLUGIN_MANIFEST_DIR).join(PLUGIN_MANIFEST_FILE), + plugin_manifest_body(), + ) + .await?; + + // Already exactly right: return without touching the tree. This is the common + // case -- two of a first turn's three rebuilds ask for the same set the first + // one just linked -- and skipping them is not merely an optimization: a wipe + // and relink would briefly empty a directory a CLI may be reading, and would + // re-derive an "added" count against a snapshot of our own making. + if previously_linked == desired { + debug!( + user_id = %user_id, + conversation_id = %conversation_id, + skills = desired.len(), + "skill_view: session skill view already matches the snapshot; left untouched" + ); + return Ok(desired.len()); + } + + // Replace the skills tree wholesale. Safe because it holds only our own + // symlinks, and REQUIRED by G2: a skill dropped from the snapshot has to + // actually disappear, which a merge-style update would not achieve. + // + // `remove_dir_all` on a directory of symlinks removes the LINKS, not their + // targets, so the user's real skill sources are untouched. + match tokio::fs::remove_dir_all(&skills_dir).await { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(ExtensionError::Io(e)), + } + tokio::fs::create_dir_all(&skills_dir).await?; + + let mut created = 0usize; + for skill in &linkable { + let target = skills_dir.join(&skill.name); + match create_symlink(&skill.source_path, &target).await { + Ok(()) => created += 1, + Err(e) => warn!( + skill = %skill.name, + conversation_id = %conversation_id, + error = %e, + "skill_view: failed to link skill into the view directory" + ), + } + } + + if !previously_linked.is_empty() { + let added = desired.difference(&previously_linked).count(); + if added > 0 { + warn!( + user_id = %user_id, + conversation_id = %conversation_id, + added, + "skill_view: skills added to a conversation that already had a view; their \ + supplementary files stay unreadable to an already-running agent until the \ + runtime restarts (directory allow-listing is a spawn argument)" + ); + } + } + + info!( + user_id = %user_id, + conversation_id = %conversation_id, + skills = created, + linkable = linkable.len(), + requested = skills.len(), + "skill_view: rebuilt session skill view" + ); + Ok(created) +} + +/// Skill names currently linked in the view. Empty when the view does not exist +/// yet, which is indistinguishable from "no skills" on purpose: both mean there +/// is no previous state to diff against. +async fn existing_link_names(skills_dir: &Path) -> HashSet { + let mut names = HashSet::new(); + let Ok(mut entries) = tokio::fs::read_dir(skills_dir).await else { + return names; + }; + while let Ok(Some(entry)) = entries.next_entry().await { + if let Some(name) = entry.file_name().to_str() { + names.insert(name.to_owned()); + } + } + names +} + +/// Drop this conversation's view. `Ok(false)` means there was nothing to remove. +pub async fn remove_view(data_dir: &Path, user_id: &str, conversation_id: &str) -> Result { + let view = view_dir(data_dir, user_id, conversation_id)?; + match tokio::fs::remove_dir_all(&view).await { + Ok(()) => { + info!( + user_id = %user_id, + conversation_id = %conversation_id, + "skill_view: removed session skill view" + ); + Ok(true) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(ExtensionError::Io(e)), + } +} + +/// Reap views whose `(user_id, conversation_id)` is absent from `live`. +/// +/// Keyed by the PAIR, not by conversation alone: two Core users can hold +/// same-shaped conversation ids, and dropping one because the other user's +/// conversation is gone would break G3. +/// +/// A view outlives its conversation only when the delete hook did not run +/// (crash, forced kill), so this runs once at startup rather than on a timer. +pub async fn cleanup_orphan_views(data_dir: &Path, live: &HashSet<(String, String)>) -> Result { + let root = view_root(data_dir); + let mut users = match tokio::fs::read_dir(&root).await { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(e) => return Err(ExtensionError::Io(e)), + }; + + let mut removed = 0usize; + while let Some(user_entry) = users.next_entry().await? { + let Some(user_id) = user_entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + let mut conversations = match tokio::fs::read_dir(user_entry.path()).await { + Ok(entries) => entries, + Err(_) => continue, + }; + while let Some(entry) = conversations.next_entry().await? { + let Some(conversation_id) = entry.file_name().to_str().map(str::to_owned) else { + continue; + }; + if live.contains(&(user_id.clone(), conversation_id.clone())) { + continue; + } + if tokio::fs::remove_dir_all(entry.path()).await.is_ok() { + removed += 1; + } + } + } + + if removed > 0 { + info!(removed, "skill_view: reaped orphan session skill views"); + } + Ok(removed) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + fn write_source_skill(root: &Path, name: &str) -> ResolvedAgentSkill { + let dir = root.join(name); + std::fs::create_dir_all(dir.join("references")).unwrap(); + std::fs::write( + dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: d\n---\nbody"), + ) + .unwrap(); + std::fs::write(dir.join("references").join("notes.md"), "REFTOKEN").unwrap(); + ResolvedAgentSkill { + name: name.to_owned(), + source_path: dir, + } + } + + #[test] + fn the_view_path_isolates_users_and_conversations() { + let a = view_dir(Path::new("/data"), "user_a", "conv_1").unwrap(); + let b = view_dir(Path::new("/data"), "user_b", "conv_1").unwrap(); + assert_eq!(a, Path::new("/data/session-skills/user_a/conv_1")); + assert_ne!(a, b, "the user segment is what keeps multi-Core installs apart"); + assert_eq!( + view_skills_dir(Path::new("/data"), "user_a", "conv_1").unwrap(), + a.join("skills") + ); + } + + /// An id reaching this function comes from storage, so traversal must be + /// refused here rather than trusted upstream. + #[test] + fn traversal_ids_are_refused() { + for bad in ["..", "../escape", "a/b", "a\\b", "", "."] { + assert!( + view_dir(Path::new("/data"), bad, "conv_1").is_err(), + "user_id {bad:?} must be refused" + ); + assert!( + view_dir(Path::new("/data"), "user_a", bad).is_err(), + "conversation_id {bad:?} must be refused" + ); + } + } + + #[tokio::test] + async fn rebuild_creates_the_plugin_manifest_and_one_link_per_skill() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let sources = tmp.path().join("sources"); + let skills = vec![ + write_source_skill(&sources, "cron"), + write_source_skill(&sources, "officecli"), + ]; + + let linked = rebuild_view(&data_dir, "user_a", "conv_1", &skills).await.unwrap(); + assert_eq!(linked, 2); + + let view = view_dir(&data_dir, "user_a", "conv_1").unwrap(); + let manifest = std::fs::read_to_string(view.join(".claude-plugin").join("plugin.json")).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&manifest).unwrap(); + // This name becomes the agent-visible skill-name PREFIX (`aionui:cron`), + // so it is user-visible text, not an internal id. + assert_eq!(parsed["name"], PLUGIN_NAME); + + // The supplementary file must be reachable THROUGH the link, which is + // what makes a symlinked skill dir usable at all. + assert_eq!( + std::fs::read_to_string(view.join("skills").join("cron").join("references").join("notes.md")).unwrap(), + "REFTOKEN" + ); + } + + /// AionUi OWNS this directory, so unlike the old workspace path there is no + /// first-write-wins: a snapshot change must produce an exact match. + #[tokio::test] + async fn rebuild_drops_skills_that_left_the_snapshot() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let sources = tmp.path().join("sources"); + let cron = write_source_skill(&sources, "cron"); + let pdf = write_source_skill(&sources, "pdf"); + + rebuild_view(&data_dir, "user_a", "conv_1", &[cron.clone(), pdf]) + .await + .unwrap(); + rebuild_view(&data_dir, "user_a", "conv_1", &[cron]).await.unwrap(); + + let skills_dir = view_skills_dir(&data_dir, "user_a", "conv_1").unwrap(); + assert!(skills_dir.join("cron").exists()); + assert!( + skills_dir.join("pdf").symlink_metadata().is_err(), + "a removed skill must not linger" + ); + } + + /// Rebuilding with an unchanged snapshot must be a no-op from the caller's + /// point of view: build-task runs it on every session open. + #[tokio::test] + async fn rebuild_is_idempotent() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let sources = tmp.path().join("sources"); + let cron = write_source_skill(&sources, "cron"); + + assert_eq!( + rebuild_view(&data_dir, "user_a", "conv_1", std::slice::from_ref(&cron)) + .await + .unwrap(), + 1 + ); + assert_eq!( + rebuild_view(&data_dir, "user_a", "conv_1", std::slice::from_ref(&cron)) + .await + .unwrap(), + 1, + "a second pass over the same snapshot links the same set, not a duplicate" + ); + + let skills_dir = view_skills_dir(&data_dir, "user_a", "conv_1").unwrap(); + let entries: Vec = std::fs::read_dir(&skills_dir) + .unwrap() + .flatten() + .map(|e| e.file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(entries, vec!["cron"]); + } + + /// Adding a skill mid-session is the R12 case: the view and index pick it up, + /// but its supplementary files stay unreadable to an already-running agent + /// because directory allow-listing is a spawn argument. The rebuild must still + /// succeed and link the new skill -- the limitation is reported, not enforced. + #[tokio::test] + async fn adding_a_skill_to_an_existing_view_still_links_it() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let sources = tmp.path().join("sources"); + let cron = write_source_skill(&sources, "cron"); + let pdf = write_source_skill(&sources, "pdf"); + + rebuild_view(&data_dir, "user_a", "conv_1", std::slice::from_ref(&cron)) + .await + .unwrap(); + assert_eq!( + rebuild_view(&data_dir, "user_a", "conv_1", &[cron, pdf]).await.unwrap(), + 2 + ); + + let skills_dir = view_skills_dir(&data_dir, "user_a", "conv_1").unwrap(); + assert!(skills_dir.join("cron").exists()); + assert!(skills_dir.join("pdf").exists(), "the added skill is linked regardless"); + } + + #[tokio::test] + async fn a_missing_source_is_skipped_without_failing_the_rest() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let sources = tmp.path().join("sources"); + let good = write_source_skill(&sources, "cron"); + let missing = ResolvedAgentSkill { + name: "ghost".to_owned(), + source_path: sources.join("ghost"), + }; + + let linked = rebuild_view(&data_dir, "user_a", "conv_1", &[good, missing]) + .await + .unwrap(); + assert_eq!(linked, 1, "one bad source must not cost the other skills"); + let skills_dir = view_skills_dir(&data_dir, "user_a", "conv_1").unwrap(); + assert!(skills_dir.join("cron").exists()); + assert!(skills_dir.join("ghost").symlink_metadata().is_err()); + } + + #[tokio::test] + async fn remove_view_is_idempotent() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let sources = tmp.path().join("sources"); + rebuild_view(&data_dir, "user_a", "conv_1", &[write_source_skill(&sources, "cron")]) + .await + .unwrap(); + + assert!(remove_view(&data_dir, "user_a", "conv_1").await.unwrap()); + assert!(!view_dir(&data_dir, "user_a", "conv_1").unwrap().exists()); + assert!(!remove_view(&data_dir, "user_a", "conv_1").await.unwrap()); + } + + /// Removing the view must never follow a skill symlink and delete the real + /// source tree — that would destroy the user's own skills. + #[tokio::test] + async fn remove_view_does_not_touch_the_real_skill_sources() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let sources = tmp.path().join("sources"); + let cron = write_source_skill(&sources, "cron"); + rebuild_view(&data_dir, "user_a", "conv_1", std::slice::from_ref(&cron)) + .await + .unwrap(); + + remove_view(&data_dir, "user_a", "conv_1").await.unwrap(); + + assert!(cron.source_path.join("SKILL.md").exists(), "the source must survive"); + assert!(cron.source_path.join("references").join("notes.md").exists()); + } + + #[tokio::test] + async fn orphan_cleanup_keeps_live_conversations_and_drops_the_rest() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("data"); + let sources = tmp.path().join("sources"); + let cron = write_source_skill(&sources, "cron"); + rebuild_view(&data_dir, "user_a", "live", std::slice::from_ref(&cron)) + .await + .unwrap(); + rebuild_view(&data_dir, "user_a", "dead", std::slice::from_ref(&cron)) + .await + .unwrap(); + rebuild_view(&data_dir, "user_b", "live", std::slice::from_ref(&cron)) + .await + .unwrap(); + + let live: HashSet<(String, String)> = [ + ("user_a".to_owned(), "live".to_owned()), + ("user_b".to_owned(), "live".to_owned()), + ] + .into_iter() + .collect(); + assert_eq!(cleanup_orphan_views(&data_dir, &live).await.unwrap(), 1); + + assert!(view_dir(&data_dir, "user_a", "live").unwrap().exists()); + assert!(!view_dir(&data_dir, "user_a", "dead").unwrap().exists()); + assert!( + view_dir(&data_dir, "user_b", "live").unwrap().exists(), + "cleanup must be keyed by (user, conversation), not by conversation alone" + ); + } + + /// An empty `live` set means "no conversations exist", which must reap every + /// view -- but it must still not reach outside the view root. + #[tokio::test] + async fn orphan_cleanup_on_a_missing_root_is_not_an_error() { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().join("never-created"); + assert_eq!(cleanup_orphan_views(&data_dir, &HashSet::new()).await.unwrap(), 0); + } + + /// A first turn rebuilds the same view three times, the last two under a + /// millisecond apart. Sequential calls never exercise that: the destructive + /// interleaving needs the wipe of one call to land inside another's relink + /// loop, which is why this bug survived a green unit suite and only appeared + /// against a real backend (`File exists` link failures plus a `skills 2 / + /// requested 4` line on a view that held all four). + /// + /// The assertion is on the RESULT, not on timing: however the calls + /// interleave, every requested skill must be linked when they have all + /// returned, and every call must report the full count. + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_rebuilds_converge_on_the_full_link_set() { + let tmp = tempfile::TempDir::new().unwrap(); + let sources = tmp.path().join("sources"); + std::fs::create_dir_all(&sources).unwrap(); + let data_dir = tmp.path().join("data"); + + let names = ["alpha", "beta", "gamma", "delta", "epsilon", "zeta"]; + let skills: Vec = names.iter().map(|n| write_source_skill(&sources, n)).collect(); + + // Same (user, conversation) from several tasks at once -- the shape the + // create / ensure_runtime / send_message trio produces in production. + let mut handles = Vec::new(); + for _ in 0..8 { + let data_dir = data_dir.clone(); + let skills = skills.clone(); + handles.push(tokio::spawn(async move { + rebuild_view(&data_dir, "user_a", "conv_1", &skills).await + })); + } + + for handle in handles { + let linked = handle.await.unwrap().expect("a concurrent rebuild must not fail"); + assert_eq!( + linked, + names.len(), + "every call must report the whole set; a short count means it raced with a wipe" + ); + } + + let on_disk = existing_link_names(&view_dir(&data_dir, "user_a", "conv_1").unwrap().join(SKILLS_SUBDIR)).await; + assert_eq!( + on_disk, + names.iter().map(|n| (*n).to_owned()).collect::>(), + "the final view must hold exactly the requested skills" + ); + } + + /// The second and third rebuild of a turn ask for the set the first already + /// linked. Those must be true no-ops: a wipe-and-relink would briefly empty a + /// directory a CLI may be reading, and would recompute an "added" count + /// against a snapshot of our own making. + #[tokio::test] + async fn an_unchanged_snapshot_leaves_the_existing_links_in_place() { + let tmp = tempfile::TempDir::new().unwrap(); + let sources = tmp.path().join("sources"); + std::fs::create_dir_all(&sources).unwrap(); + let data_dir = tmp.path().join("data"); + let skills = vec![ + write_source_skill(&sources, "alpha"), + write_source_skill(&sources, "beta"), + ]; + + assert_eq!(rebuild_view(&data_dir, "user_a", "conv_1", &skills).await.unwrap(), 2); + let skills_dir = view_dir(&data_dir, "user_a", "conv_1").unwrap().join(SKILLS_SUBDIR); + let first_link_ctime = std::fs::symlink_metadata(skills_dir.join("alpha")) + .unwrap() + .modified() + .ok(); + + assert_eq!(rebuild_view(&data_dir, "user_a", "conv_1", &skills).await.unwrap(), 2); + assert_eq!( + std::fs::symlink_metadata(skills_dir.join("alpha")) + .unwrap() + .modified() + .ok(), + first_link_ctime, + "an unchanged rebuild must not have replaced the link" + ); + + // A genuinely changed snapshot still replaces the tree exactly (G2). + assert_eq!( + rebuild_view(&data_dir, "user_a", "conv_1", &skills[..1]).await.unwrap(), + 1 + ); + assert_eq!( + existing_link_names(&skills_dir).await, + HashSet::from(["alpha".to_owned()]), + "a dropped skill must actually disappear" + ); + } +} diff --git a/crates/aionui-session-message/tests/common/mod.rs b/crates/aionui-session-message/tests/common/mod.rs index aa21c9d6d..0c1fbbab7 100644 --- a/crates/aionui-session-message/tests/common/mod.rs +++ b/crates/aionui-session-message/tests/common/mod.rs @@ -9,7 +9,6 @@ #![allow(dead_code)] use std::collections::HashMap; -use std::path::Path; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; @@ -80,15 +79,6 @@ impl SkillResolver for NoSkills { async fn resolve_skills(&self, _names: &[String]) -> Vec { Vec::new() } - - async fn link_workspace_skills( - &self, - _workspace: &Path, - _rel_dirs: &[&str], - _skills: &[ResolvedAgentSkill], - ) -> usize { - 0 - } } // ── Fake agent ────────────────────────────────────────────────────── diff --git a/crates/aionui-session/src/backend/antigravity/argv.rs b/crates/aionui-session/src/backend/antigravity/argv.rs index d9604fea6..a8c4d47ac 100644 --- a/crates/aionui-session/src/backend/antigravity/argv.rs +++ b/crates/aionui-session/src/backend/antigravity/argv.rs @@ -35,6 +35,18 @@ pub(crate) struct ArgvInput { /// An AionUi mode id, not necessarily an agy one. `accept-edits` / `plan` reach agy; /// `yolo` and `default` are host-side and filtered out (see the constants above). pub mode: Option, + /// The composed `[Assistant Rules]` block (preset context + skills index + + /// dual-channel instructions), prepended to the prompt on the FIRST + /// invocation only. + /// + /// agy is a layer-2 vendor with no prompt pipeline of its own, and until this + /// existed the backend consumed only `init.mcp_servers` — so both the + /// assistant's preset context AND its skills index were dropped, leaving an + /// `injected`-mode agy session with no skills at all. + pub injected_prefix: Option, + /// Skill-delivery args (allow-list entries). agy ignored `extra_args` + /// entirely before this. + pub extra_args: Vec, } /// How long agy may wait in print mode before abandoning the turn. @@ -66,7 +78,17 @@ fn non_blank(value: &Option) -> Option<&str> { pub(crate) fn build_argv(input: &ArgvInput) -> Vec { let mut a: Vec = Vec::with_capacity(12); a.push("-p".into()); - a.push(input.prompt.clone()); + // First invocation only. A resumed run carries agy's own history, so + // re-injecting would repeat the whole rules block on every single turn. + a.push( + match ( + non_blank(&input.injected_prefix), + non_blank(&input.resume_conversation_id), + ) { + (Some(prefix), None) => format!("{prefix}\n\n{}", input.prompt), + _ => input.prompt.clone(), + }, + ); a.push("--output-format".into()); a.push("stream-json".into()); // agy cannot prompt for permission in headless mode; with the gate shut it @@ -110,6 +132,8 @@ pub(crate) fn build_argv(input: &ArgvInput) -> Vec { a.push(v.to_owned()); } } + // Skill-delivery args last, so they cannot displace anything above. + a.extend(input.extra_args.iter().cloned()); a } @@ -124,9 +148,60 @@ mod tests { workspace: Some("/w".into()), model: None, mode: None, + injected_prefix: None, + extra_args: Vec::new(), } } + /// agy spawns a fresh `-p` per turn and resumes with `--conversation`, so the + /// injected rules belong on the FIRST invocation only — afterwards agy carries + /// its own history and re-injecting would repeat the block every turn. + #[test] + fn the_first_invocation_prepends_the_injected_rules_to_the_prompt() { + let mut input = base(); + input.injected_prefix = + Some("[Assistant Rules]\n## Available Skills\n- **cron**: d\n[/Assistant Rules]".into()); + input.resume_conversation_id = None; + + let prompt = flag_value(&build_argv(&input), "-p").unwrap().to_owned(); + assert!(prompt.starts_with("[Assistant Rules]"), "{prompt}"); + assert!(prompt.contains("## Available Skills")); + assert!(prompt.ends_with("hello"), "the user's own text stays last: {prompt}"); + } + + #[test] + fn a_resumed_invocation_does_not_re_inject_the_rules() { + let mut input = base(); + input.injected_prefix = Some("[Assistant Rules]\nrules\n[/Assistant Rules]".into()); + input.resume_conversation_id = Some("conv-1".into()); + + assert_eq!(flag_value(&build_argv(&input), "-p").unwrap(), "hello"); + } + + #[test] + fn a_blank_prefix_is_treated_as_absent() { + let mut input = base(); + input.injected_prefix = Some(" \n".into()); + assert_eq!(flag_value(&build_argv(&input), "-p").unwrap(), "hello"); + } + + /// Skill allow-listing rides on `extra_args`, which this backend previously + /// ignored. The workspace `--add-dir` must survive alongside it. + #[test] + fn caller_extra_args_reach_the_spawn_alongside_the_workspace_add_dir() { + let mut input = base(); + input.extra_args = vec!["--add-dir".into(), "/src/cron".into()]; + let a = build_argv(&input); + + assert_eq!( + a.iter().filter(|arg| *arg == "--add-dir").count(), + 2, + "the workspace entry plus the allow-listed skill source: {a:?}" + ); + assert!(a.windows(2).any(|w| w == ["--add-dir", "/w"])); + assert!(a.windows(2).any(|w| w == ["--add-dir", "/src/cron"])); + } + fn flag_value<'a>(argv: &'a [String], flag: &str) -> Option<&'a str> { argv.windows(2).find(|w| w[0] == flag).map(|w| w[1].as_str()) } diff --git a/crates/aionui-session/src/backend/antigravity/conn.rs b/crates/aionui-session/src/backend/antigravity/conn.rs index d8eaa7342..fe6fd6ac4 100644 --- a/crates/aionui-session/src/backend/antigravity/conn.rs +++ b/crates/aionui-session/src/backend/antigravity/conn.rs @@ -26,9 +26,8 @@ use futures_util::stream::BoxStream; use tokio::sync::{Mutex, broadcast, oneshot}; use super::argv::{ArgvInput, build_argv}; -use super::mcp_config::write_mcp_config; use super::models::probe_models; -use super::skills::scan_skill_commands; +use super::skills::skill_commands_from_dirs; use super::translate::Translator; use super::wire::parse_line; use crate::backend::cli_version::session_drift_notice; @@ -250,21 +249,22 @@ impl BackendConnection for AntigravityConnection { )); } }; - // agy reads MCP servers from files only — there is no per-run flag — so - // the session's servers (team coordination first, then the user's) have - // to land in the workspace before the first turn spawns. - if let Some(cwd) = config.cwd.as_deref() - && let Err(e) = write_mcp_config(std::path::Path::new(cwd), &config.init.mcp_servers) - { - // Not fatal: the session still runs, just without MCP tools. - tracing::warn!(error = %e, "antigravity: could not write mcp_config.json; MCP tools will be unavailable"); - } - - let slash_commands = config - .cwd - .as_deref() - .map(|cwd| scan_skill_commands(std::path::Path::new(cwd))) - .unwrap_or_default(); + // No workspace MCP file is written. agy does NOT read + // `{workspace}/.agents/mcp_config.json`: measured with a purpose-built + // stdio MCP server under `--dangerously-skip-permissions` (which + // `argv.rs` always passes), the same server is invoked when configured + // in `~/.gemini/config/mcp_config.json` and is NOT invoked from the + // workspace file, where agy logs `empty component: prompt section + // "mcp_servers"`. Its own docs list only the global path and + // `plugins//mcp_config.json`, and `descriptor.rs` already declares + // agy with no MCP transport, which routes Team coordination down the CLI + // it was silently using anyway. So the writer was producing a file with + // no consumer -- and doing it inside the user's workspace. + + // From the session's resolved skill dirs, not from the workspace: AionUi + // no longer writes `.agents/skills`, and this source is exactly the + // conversation's enabled set. + let slash_commands = skill_commands_from_dirs(&config.init.skill_dirs); let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); let backend = Arc::new(AntigravitySessionBackend { @@ -658,6 +658,13 @@ impl AntigravitySessionBackend { workspace: self.config.cwd.clone(), model: self.effective_model(), mode: self.effective_mode(), + // agy has no prompt pipeline of its own, so the composed rules block + // rides in through `init.preset_context` and `build_argv` prepends it + // on the first invocation only. Before this, the backend read nothing + // from `init` except `mcp_servers` — dropping both the assistant's + // preset context and its skills index. + injected_prefix: self.config.init.preset_context.clone(), + extra_args: self.config.extra_args.clone(), }; let mut spawn_env = self.config.spawn_env.clone(); if let Some(cwd) = self.config.cwd.as_deref() { diff --git a/crates/aionui-session/src/backend/antigravity/mcp_config.rs b/crates/aionui-session/src/backend/antigravity/mcp_config.rs deleted file mode 100644 index 62f72bf7a..000000000 --- a/crates/aionui-session/src/backend/antigravity/mcp_config.rs +++ /dev/null @@ -1,219 +0,0 @@ -//! Writes the session's MCP servers into `/.agents/mcp_config.json`. -//! -//! **agy does not read that file.** Measured 2026-08-19 with a purpose-built -//! stdio MCP server that records when its tool runs, under -//! `--dangerously-skip-permissions` (which `argv.rs` always passes): the same -//! server is called when configured in `~/.gemini/config/mcp_config.json` and -//! is NOT called from the workspace file, where agy logs -//! `empty component: prompt section "mcp_servers"`. Its own documentation -//! agrees — `~/.gemini/antigravity-cli/builtin/skills/agy-customizations/docs/ -//! mcp_servers.md` §Location lists only the global path and -//! `plugins//mcp_config.json`. -//! -//! An earlier version of this comment cited -//! `live_antigravity_team_mcp_tools_call_and_runtime_env` as having verified -//! the workspace path. That was circular: the test asserts by searching every -//! tool frame's JSON for the substring `team_members`, and its own prompt -//! contains that string, so it can pass with no MCP binding at all. -//! -//! The mapping below is kept — it is correct, and is what a global or plugin -//! writer would need — but nothing consumes the file it produces today. -//! `descriptor.rs` therefore declares agy with no MCP transport, which routes -//! Team coordination down the CLI it was already silently using. -//! -//! agy supports exactly two transports, Stdio and SSE (verified: -//! `~/.gemini/antigravity-cli/builtin/skills/agy-customizations/docs/mcp_servers.md`), -//! so an HTTP server is dropped -//! rather than mistranslated. - -use std::path::Path; - -use crate::backend::{McpServerSpec, McpTransport}; - -/// Workspace customization directory agy scans. -const AGENTS_DIR: &str = ".agents"; - -fn pairs_to_object(pairs: &[(String, String)]) -> serde_json::Map { - pairs - .iter() - .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) - .collect() -} - -/// Serialize `servers` into the workspace's `mcp_config.json`. -/// -/// An empty (or entirely unsupported) server set REMOVES the file: leaving a -/// stale one would hand this session the previous session's servers. -pub(crate) fn write_mcp_config(workspace: &Path, servers: &[McpServerSpec]) -> std::io::Result<()> { - let path = workspace.join(AGENTS_DIR).join("mcp_config.json"); - - let mut map = serde_json::Map::new(); - for server in servers { - let entry = match &server.transport { - McpTransport::Stdio { command, args, env } => serde_json::json!({ - "command": command, - "args": args, - "env": pairs_to_object(env), - }), - McpTransport::Sse { url, headers } => serde_json::json!({ - "serverUrl": url, - "headers": pairs_to_object(headers), - }), - McpTransport::Http { .. } => { - // Emitting this as `serverUrl` would write a config that looks - // valid and then never connects — SSE and streamable-HTTP are - // different protocols. - tracing::warn!( - backend = "antigravity", - server = %server.name, - transport = "streamable_http", - "antigravity: skipping MCP server — agy supports stdio and SSE only" - ); - continue; - } - }; - map.insert(server.name.clone(), entry); - } - - if map.is_empty() { - if path.exists() { - std::fs::remove_file(&path)?; - } - return Ok(()); - } - - std::fs::create_dir_all(workspace.join(AGENTS_DIR))?; - let body = serde_json::json!({ "mcpServers": map }); - std::fs::write(path, serde_json::to_vec_pretty(&body)?) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::backend::{McpServerSpec, McpTransport}; - - fn read_config(dir: &std::path::Path) -> serde_json::Value { - let raw = std::fs::read_to_string(dir.join(".agents/mcp_config.json")).unwrap(); - serde_json::from_str(&raw).unwrap() - } - - #[test] - fn stdio_spec_maps_to_agys_command_shape() { - // Deliberately inverted 2026-08-19: agy speaks STDIO, but we have no - // way to hand it a server (see the module docs), so the descriptor - // declares none. The mapping below is still asserted — it stays correct - // and is what a global or plugin writer would reuse. - assert!( - !crate::backend::backend_capability_descriptor("antigravity") - .unwrap() - .mcp - .stdio - ); - let dir = tempfile::tempdir().unwrap(); - let servers = vec![McpServerSpec { - name: "aionui-team".into(), - transport: McpTransport::Stdio { - command: "/opt/aionui/backend".into(), - args: vec!["mcp-team-stdio".into()], - env: vec![ - ("TEAM_MCP_PORT".into(), "8931".into()), - ("TEAM_MCP_TOKEN".into(), "tok".into()), - ], - }, - }]; - write_mcp_config(dir.path(), &servers).unwrap(); - - let s = &read_config(dir.path())["mcpServers"]["aionui-team"]; - assert_eq!(s["command"], "/opt/aionui/backend"); - assert_eq!(s["args"][0], "mcp-team-stdio"); - // agy wants env as an OBJECT; we carry it as ordered pairs internally. - assert_eq!(s["env"]["TEAM_MCP_PORT"], "8931"); - assert_eq!(s["env"]["TEAM_MCP_TOKEN"], "tok"); - } - - #[test] - fn sse_spec_maps_to_server_url_and_headers() { - // Deliberately inverted 2026-08-19: agy speaks SSE, but we have no - // way to hand it a server (see the module docs), so the descriptor - // declares none. The mapping below is still asserted — it stays correct - // and is what a global or plugin writer would reuse. - assert!( - !crate::backend::backend_capability_descriptor("antigravity") - .unwrap() - .mcp - .sse - ); - let dir = tempfile::tempdir().unwrap(); - let servers = vec![McpServerSpec { - name: "remote".into(), - transport: McpTransport::Sse { - url: "https://api.example.com/sse".into(), - headers: vec![("Authorization".into(), "Bearer t".into())], - }, - }]; - write_mcp_config(dir.path(), &servers).unwrap(); - - let s = &read_config(dir.path())["mcpServers"]["remote"]; - assert_eq!(s["serverUrl"], "https://api.example.com/sse"); - assert_eq!(s["headers"]["Authorization"], "Bearer t"); - } - - #[test] - fn http_transport_is_skipped_rather_than_passed_off_as_sse() { - assert!( - !crate::backend::backend_capability_descriptor("antigravity") - .unwrap() - .mcp - .streamable_http - ); - // agy supports stdio and SSE only. Writing an HTTP server as - // `serverUrl` produces a config that looks fine and then fails at - // runtime with an opaque timeout, because the protocols differ. - let dir = tempfile::tempdir().unwrap(); - let servers = vec![ - McpServerSpec { - name: "unsupported".into(), - transport: McpTransport::Http { - url: "https://x/mcp".into(), - headers: vec![], - }, - }, - McpServerSpec { - name: "kept".into(), - transport: McpTransport::Stdio { - command: "node".into(), - args: vec![], - env: vec![], - }, - }, - ]; - write_mcp_config(dir.path(), &servers).unwrap(); - - let cfg = read_config(dir.path()); - assert!(cfg["mcpServers"].get("unsupported").is_none()); - assert!(cfg["mcpServers"].get("kept").is_some()); - } - - #[test] - fn an_empty_server_list_removes_a_stale_config() { - // Leftover servers would silently give this session the previous - // session's tools. - let dir = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(dir.path().join(".agents")).unwrap(); - std::fs::write( - dir.path().join(".agents/mcp_config.json"), - r#"{"mcpServers":{"old":{"command":"x"}}}"#, - ) - .unwrap(); - - write_mcp_config(dir.path(), &[]).unwrap(); - assert!(!dir.path().join(".agents/mcp_config.json").exists()); - } - - #[test] - fn writing_no_servers_into_a_clean_workspace_creates_nothing() { - let dir = tempfile::tempdir().unwrap(); - write_mcp_config(dir.path(), &[]).unwrap(); - assert!(!dir.path().join(".agents/mcp_config.json").exists()); - } -} diff --git a/crates/aionui-session/src/backend/antigravity/mod.rs b/crates/aionui-session/src/backend/antigravity/mod.rs index 726deae61..b02e48d69 100644 --- a/crates/aionui-session/src/backend/antigravity/mod.rs +++ b/crates/aionui-session/src/backend/antigravity/mod.rs @@ -16,7 +16,6 @@ mod argv; pub(crate) const CODE_STEPS_FAILED: &str = "ANTIGRAVITY_STEPS_FAILED"; mod conn; -mod mcp_config; mod models; mod skills; mod translate; diff --git a/crates/aionui-session/src/backend/antigravity/skills.rs b/crates/aionui-session/src/backend/antigravity/skills.rs index ec7000157..39114419d 100644 --- a/crates/aionui-session/src/backend/antigravity/skills.rs +++ b/crates/aionui-session/src/backend/antigravity/skills.rs @@ -1,18 +1,21 @@ -//! Slash commands derived from the workspace's agy skills. +//! Slash commands derived from this session's agy skills. //! //! agy exposes no way to LIST its commands: `agy commands` / `agy skills` drop //! into the TUI, and headless mode has no equivalent. But `-p "/"` -//! does invoke a skill (verified), and AionUi provisions those skills itself -//! under `.agents/skills/`. So the command list is read off the skill files -//! rather than asked for. +//! does invoke a skill (verified), so the command list is read off the skill +//! files rather than asked for. +//! +//! The files are the session's RESOLVED skill directories. This used to scan +//! `{workspace}/.agents/skills`, which AionUi no longer creates. The resolved +//! dirs are a strictly better source anyway: they are exactly this +//! conversation's enabled skills, so a stale residue left in the workspace by +//! an older build can no longer show up in the picker. use std::path::Path; +use crate::backend::SkillDirSpec; use crate::capability::SlashCommandInfo; -/// Where agy looks for workspace skills, and where AionUi provisions them. -const SKILLS_DIR: &str = ".agents/skills"; - /// Pull `name` / `description` out of a `SKILL.md` YAML frontmatter block. /// /// Deliberately minimal: only the two scalar keys we need, so a skill file @@ -69,21 +72,17 @@ fn parse_frontmatter(md: &str) -> Option { }) } -/// Scan `/.agents/skills/*/SKILL.md` for invokable skills. +/// Read `{skill_dir}/SKILL.md` for each of the session's skills. /// -/// Never fails: an unreadable or malformed skill is skipped, because a bad +/// Never fails: an unreadable or malformed skill is skipped, because one bad /// file must not cost the user their whole command list. -pub(crate) fn scan_skill_commands(workspace: &Path) -> Vec { - let Ok(entries) = std::fs::read_dir(workspace.join(SKILLS_DIR)) else { - return Vec::new(); - }; - let mut out: Vec = entries - .flatten() - .filter_map(|e| std::fs::read_to_string(e.path().join("SKILL.md")).ok()) +pub(crate) fn skill_commands_from_dirs(skills: &[SkillDirSpec]) -> Vec { + let mut out: Vec = skills + .iter() + .filter_map(|skill| std::fs::read_to_string(Path::new(&skill.path).join("SKILL.md")).ok()) .filter_map(|md| parse_frontmatter(&md)) .collect(); - // Directory order is filesystem-dependent; a stable list keeps the picker - // from reshuffling between sessions. + // A stable list keeps the picker from reshuffling between sessions. out.sort_by(|a, b| a.name.cmp(&b.name)); out } @@ -92,60 +91,87 @@ pub(crate) fn scan_skill_commands(workspace: &Path) -> Vec { mod tests { use super::*; - fn write_skill(root: &Path, dir: &str, body: &str) { - let d = root.join(SKILLS_DIR).join(dir); - std::fs::create_dir_all(&d).unwrap(); - std::fs::write(d.join("SKILL.md"), body).unwrap(); + /// Write a skill as a standalone SOURCE directory (what the resolver hands + /// us), not under a workspace `.agents/skills` tree. + fn write_skill(root: &Path, name: &str, body: &str) -> SkillDirSpec { + let dir = root.join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("SKILL.md"), body).unwrap(); + SkillDirSpec { + name: name.to_owned(), + path: dir.to_string_lossy().into_owned(), + } } #[test] fn reads_name_and_description_from_frontmatter() { let dir = tempfile::tempdir().unwrap(); - write_skill( + let skill = write_skill( dir.path(), "aionui-probe", "---\nname: aionui-probe\ndescription: Probe skill.\n---\n\n# body\n", ); - let cmds = scan_skill_commands(dir.path()); + let cmds = skill_commands_from_dirs(&[skill]); assert_eq!(cmds.len(), 1); assert_eq!(cmds[0].name, "aionui-probe"); assert_eq!(cmds[0].description.as_deref(), Some("Probe skill.")); } + /// The command list must come from the session's resolved skills, NOT from + /// the workspace: AionUi no longer writes there, and a residue left by an + /// older build must not reappear in the picker. + #[test] + fn a_stale_workspace_residue_is_not_listed() { + let dir = tempfile::tempdir().unwrap(); + let cron = write_skill(dir.path(), "cron", "---\nname: cron\ndescription: Schedule.\n---\n"); + + let workspace = dir.path().join("workspace"); + let residue = workspace.join(".agents").join("skills").join("residue"); + std::fs::create_dir_all(&residue).unwrap(); + std::fs::write(residue.join("SKILL.md"), "---\nname: residue\n---\n").unwrap(); + + let cmds = skill_commands_from_dirs(&[cron]); + assert_eq!(cmds.iter().map(|c| c.name.as_str()).collect::>(), vec!["cron"]); + } + #[test] fn supports_the_folded_description_agys_own_skills_use() { // agy's builtin skills (e.g. agy-customizations) write `description: >-` // followed by indented lines. let dir = tempfile::tempdir().unwrap(); - write_skill( + let skill = write_skill( dir.path(), "folded", "---\nname: folded\ndescription: >-\n First part\n second part.\n---\n\nbody\n", ); - let cmds = scan_skill_commands(dir.path()); + let cmds = skill_commands_from_dirs(&[skill]); assert_eq!(cmds[0].description.as_deref(), Some("First part second part.")); } #[test] - fn a_malformed_skill_is_skipped_not_fatal() { + fn a_malformed_or_missing_skill_is_skipped_not_fatal() { // One broken file must not cost the user the whole command list. let dir = tempfile::tempdir().unwrap(); - write_skill(dir.path(), "broken", "no frontmatter here"); - write_skill(dir.path(), "good", "---\nname: good\n---\n"); + let broken = write_skill(dir.path(), "broken", "no frontmatter here"); + let good = write_skill(dir.path(), "good", "---\nname: good\n---\n"); + let missing = SkillDirSpec { + name: "ghost".to_owned(), + path: dir.path().join("ghost").to_string_lossy().into_owned(), + }; - let cmds = scan_skill_commands(dir.path()); + let cmds = skill_commands_from_dirs(&[broken, good, missing]); assert_eq!(cmds.iter().map(|c| c.name.as_str()).collect::>(), vec!["good"]); } #[test] fn results_are_sorted_so_the_picker_does_not_reshuffle() { let dir = tempfile::tempdir().unwrap(); - write_skill(dir.path(), "zeta", "---\nname: zeta\n---\n"); - write_skill(dir.path(), "alpha", "---\nname: alpha\n---\n"); + let zeta = write_skill(dir.path(), "zeta", "---\nname: zeta\n---\n"); + let alpha = write_skill(dir.path(), "alpha", "---\nname: alpha\n---\n"); - let cmds = scan_skill_commands(dir.path()); + let cmds = skill_commands_from_dirs(&[zeta, alpha]); assert_eq!( cmds.iter().map(|c| c.name.as_str()).collect::>(), vec!["alpha", "zeta"] @@ -153,8 +179,7 @@ mod tests { } #[test] - fn a_workspace_without_skills_yields_nothing() { - let dir = tempfile::tempdir().unwrap(); - assert!(scan_skill_commands(dir.path()).is_empty()); + fn a_session_without_skills_yields_nothing() { + assert!(skill_commands_from_dirs(&[]).is_empty()); } } diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs index 075b9e084..40cf76912 100644 --- a/crates/aionui-session/src/backend/claude_conn.rs +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -4028,6 +4028,58 @@ mod tests { ); } + /// Layer-1 skill delivery arrives as already-substituted `extra_args`, so the + /// spawn must carry the plugin root AND one allow-list entry per skill. + /// + /// Load-bearing: `--add-dir` is declared variadic in `claude --help` + /// (``), so it would be easy to assume repeating the flag + /// collapses to the last value. A live paired probe against claude 2.1.231 + /// showed the opposite -- with two `--add-dir` flags both out-of-cwd files + /// were read; with none, both were refused. This test pins the wiring half of + /// that: `prepend_args` is a plain concat and must not de-duplicate. + #[test] + fn every_skill_delivery_arg_survives_into_the_spawn() { + let init = build_claude_init_args(&SessionConfig { + mode: Some("default".into()), + ..Default::default() + }); + let extra = vec![ + "--plugin-dir".to_string(), + "/data/session-skills/u/c".to_string(), + "--add-dir".to_string(), + "/src/cron".to_string(), + "--add-dir".to_string(), + "/src/pdf".to_string(), + ]; + let spawn = prepend_args(&init, &extra); + + assert_eq!( + spawn.iter().filter(|arg| *arg == "--add-dir").count(), + 2, + "one allow-list entry per skill must survive; the flag is repeated, not merged" + ); + assert!(spawn.windows(2).any(|w| w == ["--add-dir", "/src/cron"])); + assert!(spawn.windows(2).any(|w| w == ["--add-dir", "/src/pdf"])); + assert!( + spawn + .windows(2) + .any(|w| w == ["--plugin-dir", "/data/session-skills/u/c"]) + ); + // The fail-closed flag must still be present and unmoved: skill delivery + // must never displace the permission mode. + assert!(spawn.windows(2).any(|w| w == ["--permission-mode", "default"])); + } + + /// A vendor with no skills contributes no args, so the spawn is unchanged. + #[test] + fn no_skill_delivery_args_leaves_the_spawn_untouched() { + let init = build_claude_init_args(&SessionConfig { + mode: Some("default".into()), + ..Default::default() + }); + assert_eq!(prepend_args(&init, &[]), init); + } + /// `prepend_args` keeps init flags BEFORE caller `extra_args` (a duplicate caller /// flag then wins by appearing later on the CLI). #[test] diff --git a/crates/aionui-session/src/backend/codex_conn.rs b/crates/aionui-session/src/backend/codex_conn.rs index eed3b088b..aac22b9cb 100644 --- a/crates/aionui-session/src/backend/codex_conn.rs +++ b/crates/aionui-session/src/backend/codex_conn.rs @@ -476,6 +476,48 @@ fn initialize_params() -> HandshakeParams { })) } +/// `skills/extraRoots/set` params, or `None` when this session has no skill view +/// to register (a non-protocol vendor, or an empty skill snapshot). +/// +/// Wire shape verified against codex-cli 0.146.0's own generated schema +/// (`codex app-server generate-json-schema`, `v2/SkillsExtraRootsSetParams.json`): +/// the only property is `extraRoots`, an array of `AbsolutePathBuf`, and it is +/// required. A live `codex app-server --stdio` probe confirmed the behaviour +/// end-to-end: the request answers `{}`, codex then pushes a `skills/changed` +/// notification, and `skills/list` reports the skill with `errors: []`. +/// +/// Two properties that probe pinned, both load-bearing here: +/// * A SYMLINKED skill directory is discovered — which is what the whole view +/// directory design depends on. +/// * `path` comes back as the REAL source path, not the link. So a CLI checking +/// canonical paths would not match a view entry, which is why allow-listing +/// targets the real source dirs instead. +/// +/// ⚠️ R2' HARD CONSTRAINT — DO NOT REMOVE. +/// `extraRoots` is PROCESS-scoped, not thread-scoped. Two independent +/// confirmations: the params carry NO `threadId` (thread-level requests such as +/// `ThreadForkParams` do), and the live probe reported the registered skill with +/// `scope: "user"`. Isolation (one conversation's skills staying out of another) +/// therefore holds ONLY because this file opens one process per logical session — +/// see the `CodexConnection` doc comment, "P1 opens one process per logical +/// session (multiplexing is a later refinement)". +/// +/// If thread multiplexing is ever enabled, process-wide extra roots WILL leak one +/// conversation's skills into another. Codex layer-1 delivery and thread reuse +/// are MUTUALLY EXCLUSIVE. Before enabling reuse, either switch to per-skill +/// `skills/config/write { name|path, enabled }` (present in the same schema) or +/// drop codex to `injected`, and land a cross-conversation skill isolation +/// regression test in the SAME change. +fn skills_extra_roots_params(config: &SessionConfig) -> Option { + let root = config + .init + .skill_view_skills_dir + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())?; + Some(HandshakeParams(json!({ "extraRoots": [root] }))) +} + /// `thread/start` params (Fresh / lost-Resume). approvalPolicy/sandbox are valid /// AskForApproval/SandboxMode enum values; cwd threaded from config. /// @@ -1381,6 +1423,25 @@ impl CodexSessionBackend { *self.resume_poison.lock().await = None; self.write_frame(initialize_params().into_frame(self.next_rpc_id(), "initialize")) .await?; + + // Layer 1 (protocol): register this conversation's skill view. Sent after + // `initialize` (the probe order) and before thread start/resume so the + // first turn already sees the skills. + // + // Fire-and-forget by design: the response is an empty object, so there is + // nothing to claim, and a rejection must NOT fail the session — layer 2's + // dual channel still covers skills. A rejection therefore surfaces only as + // codex-side output, which is an accepted gap rather than a silent one. + if let Some(params) = skills_extra_roots_params(&self.wake.config) { + self.write_frame(params.into_frame(self.next_rpc_id(), "skills/extraRoots/set")) + .await?; + tracing::info!( + backend = "codex", + mode = "protocol", + "skill_delivery: registered the session skill view as a codex extra root" + ); + } + match mode { HandshakeMode::Resume(tid) => { *self.thread_binding.lock().await = Some(tid.to_string()); @@ -7815,6 +7876,57 @@ mod tests { assert_eq!(frame["params"]["clientInfo"]["name"], "aionui-session"); } + /// Exact wire shape, per codex-cli 0.146.0's own generated schema + /// (`v2/SkillsExtraRootsSetParams.json`): one required `extraRoots` array of + /// absolute paths, and NOTHING else. The absent `threadId` is the schema-level + /// evidence that this request is process-scoped (see R2' on the builder). + #[test] + fn extra_roots_frame_carries_only_the_skills_root() { + let params = skills_extra_roots_params(&SessionConfig { + init: crate::backend::SessionInit { + skill_view_skills_dir: Some("/data/session-skills/u/c/skills".into()), + ..Default::default() + }, + ..Default::default() + }) + .expect("a session with a skill view must produce a frame"); + let frame = params.into_frame(3, "skills/extraRoots/set"); + + assert_eq!(frame["method"], "skills/extraRoots/set"); + assert_eq!(frame["params"]["extraRoots"][0], "/data/session-skills/u/c/skills"); + assert_eq!( + frame["params"].as_object().map(|o| o.len()), + Some(1), + "the schema declares exactly one property; anything extra is a guess" + ); + assert!( + frame["params"].get("threadId").is_none(), + "no threadId in the schema — the request is process-scoped (R2')" + ); + } + + /// The SKILLS root, not the plugin root. codex scans `{root}/{name}/SKILL.md` + /// directly, so handing it the plugin root would find nothing — and it answers + /// `{}` either way, so the mistake would be silent. + #[test] + fn no_skill_view_means_no_extra_roots_frame() { + assert!( + skills_extra_roots_params(&SessionConfig::default()).is_none(), + "a conversation with no skills must not touch the process-wide extra roots" + ); + assert!( + skills_extra_roots_params(&SessionConfig { + init: crate::backend::SessionInit { + skill_view_skills_dir: Some(" ".into()), + ..Default::default() + }, + ..Default::default() + }) + .is_none(), + "a blank path must be treated as absent, not registered as a root" + ); + } + /// thread/start params thread cwd from config; approvalPolicy/sandbox are valid /// codex enum values. MODEL IS NEVER EMBEDDED (codex-model-gating regression fix): /// the model binds the whole thread and cannot be validated at this instant diff --git a/crates/aionui-session/src/backend/mod.rs b/crates/aionui-session/src/backend/mod.rs index fda6b7a18..1a348a65f 100644 --- a/crates/aionui-session/src/backend/mod.rs +++ b/crates/aionui-session/src/backend/mod.rs @@ -197,6 +197,17 @@ pub enum McpTransport { }, } +/// One resolved skill: its bare snapshot name and its real on-disk directory. +/// +/// NB: the name here is the `extra.skills` name (`cron`). Under plugin-based +/// delivery the agent sees a PREFIXED name (`aionui:cron`) — do not assume the +/// two sides match when correlating. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SkillDirSpec { + pub name: String, + pub path: String, +} + /// The session-INITIALIZATION surface (Wave 0c) the legacy ai-agent factory /// provided, carried neutrally so each backend's `open_session` serializes it into /// its own wire shape. `Default` = empty = byte-identical to the pre-0c handshake, @@ -210,14 +221,25 @@ pub struct SessionInit { /// Skill ids/names to surface to the agent on the first turn (acp/aionrs /// deliver these via the first prompt, not a `session/new` param). /// - /// NB: claude/codex direct-CLI backends intentionally do NOT consume this — - /// skills reach them as workspace symlinks (conversation service links the - /// resolved skills into the agent's `native_skills_dirs`, e.g. - /// `.claude/skills` / `.codex/skills`) and both CLIs discover them natively - /// (codex LIVE-verified 0.144.1: `skills/list` returns a `/.codex/skills` - /// entry as scope=repo; see samples/codex-cli/0.144.1/_probe_workspace_skills.py). + /// NB: claude/codex direct-CLI backends intentionally do NOT consume this. + /// They receive skills through AionUi's own per-conversation view directory + /// instead — claude via a `--plugin-dir` launch flag, codex via a + /// `skills/extraRoots/set` request carrying [`Self::skill_view_skills_dir`]. /// The field is carried for the aionrs/acp first-prompt delivery path only. pub skills: Vec, + /// Absolute path to this conversation's SKILLS ROOT inside AionUi's own view + /// directory (`{view}/skills`, which directly holds `{name}/SKILL.md`). + /// + /// Populated only for protocol-mode delivery, where the backend has to send + /// the path itself. `None` = no protocol delivery for this session, which is + /// also what an empty skill snapshot produces. + pub skill_view_skills_dir: Option, + /// The conversation's resolved skills as REAL source directories. + /// + /// For backends that need name+path without reading the workspace — agy + /// builds its slash-command list from these rather than scanning + /// `{cwd}/.agents/skills`, which AionUi no longer creates. Empty = no skills. + pub skill_dirs: Vec, /// Composed system prompt / preset context (the `compose_preset_context` /// output + aionrs `preset_rules`-merged prompt). Delivered first-message. pub preset_context: Option, diff --git a/crates/aionui-session/src/lib.rs b/crates/aionui-session/src/lib.rs index c64d76b04..3e2e381e4 100644 --- a/crates/aionui-session/src/lib.rs +++ b/crates/aionui-session/src/lib.rs @@ -58,7 +58,7 @@ pub use backend::{ CodexConnection, CodexSessionBackend, Command, CommandMeta, CommandReceipt, ContentBlock, ConversationSession, McpServerSpec, McpTransport, MsgStatus, Orchestrator, PendingMessage, PendingPermissionView, PermissionDecision, QuestionAnswer, SessionBackend, SessionConfig, SessionEnvelope, SessionInfoKind, SessionInit, SessionSpec, - StateSnapshot, Tier2Checkpoint, TransitionReason, VERIFIED_AGY_VERSION, VERIFIED_CLAUDE_VERSION, + SkillDirSpec, StateSnapshot, Tier2Checkpoint, TransitionReason, VERIFIED_AGY_VERSION, VERIFIED_CLAUDE_VERSION, VERIFIED_CODEX_VERSION, VersionDrift, VersionVerdict, acp_capabilities, antigravity_capabilities, backend_capability_descriptor, backend_capability_descriptors, classify_cli_version, codex_capabilities, codex_shell_environment_policy_args, command_name, effective_agent_capabilities, parse_cli_version, rehydrate, diff --git a/crates/aionui-skill-runtime/Cargo.toml b/crates/aionui-skill-runtime/Cargo.toml new file mode 100644 index 000000000..8b063d21c --- /dev/null +++ b/crates/aionui-skill-runtime/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "aionui-skill-runtime" +version.workspace = true +edition.workspace = true + +[dependencies] +aionui-api-types.workspace = true +aionui-common.workspace = true +aionui-db.workspace = true +aionui-extension.workspace = true +aionui-ai-agent.workspace = true +async-trait.workspace = true +axum.workspace = true +serde.workspace = true +serde_json.workspace = true +thiserror.workspace = true +tokio.workspace = true +tracing.workspace = true + +[dev-dependencies] +sqlx.workspace = true +tempfile = "3" +tokio = { workspace = true, features = ["test-util"] } +tower.workspace = true +http-body-util.workspace = true diff --git a/crates/aionui-skill-runtime/src/error.rs b/crates/aionui-skill-runtime/src/error.rs new file mode 100644 index 000000000..0f45a8889 --- /dev/null +++ b/crates/aionui-skill-runtime/src/error.rs @@ -0,0 +1,66 @@ +use aionui_api_types::SkillRuntimeErrorCode; + +/// Crate-owned error, mapped to an HTTP status + wire code only at the route +/// boundary (per the domain-crate convention: service code stays framework-free). +#[derive(Debug, thiserror::Error)] +pub enum SkillRuntimeError { + #[error("runtime auth failed")] + RuntimeAuthFailed, + + #[error("conversation not found")] + ConversationNotFound, + + /// Distinct from `SkillNotFound` on purpose. "Enabled somewhere else" and + /// "does not exist" call for different agent behaviour (stop asking vs. + /// report a broken install) and different operator diagnosis (a snapshot + /// mismatch vs. a missing directory). + #[error("skill '{name}' is not enabled in this conversation")] + SkillNotEnabled { name: String }, + + #[error("skill '{name}' has no resolvable source directory")] + SkillNotFound { name: String }, + + #[error("invalid path: {reason}")] + InvalidPath { reason: String }, + + #[error("schema validation failed: {reason}")] + SchemaValidation { reason: String }, + + #[error("read failed: {reason}")] + ReadFailed { reason: String }, +} + +impl SkillRuntimeError { + pub fn http_status(&self) -> u16 { + match self { + Self::RuntimeAuthFailed => 401, + // 403, not 404: the caller is authenticated and the skill may well + // exist -- it is simply outside this conversation's allow-list. A 404 + // would invite the agent to retry with variations. + Self::SkillNotEnabled { .. } => 403, + Self::ConversationNotFound | Self::SkillNotFound { .. } => 404, + Self::InvalidPath { .. } | Self::SchemaValidation { .. } => 400, + Self::ReadFailed { .. } => 500, + } + } + + pub fn code(&self) -> SkillRuntimeErrorCode { + match self { + Self::RuntimeAuthFailed => SkillRuntimeErrorCode::RuntimeAuthFailed, + Self::ConversationNotFound => SkillRuntimeErrorCode::ConversationNotFound, + Self::SkillNotEnabled { .. } => SkillRuntimeErrorCode::SkillNotEnabled, + Self::SkillNotFound { .. } => SkillRuntimeErrorCode::SkillNotFound, + Self::InvalidPath { .. } => SkillRuntimeErrorCode::InvalidPath, + Self::SchemaValidation { .. } => SkillRuntimeErrorCode::SchemaValidationFailed, + Self::ReadFailed { .. } => SkillRuntimeErrorCode::TransportUnavailable, + } + } +} + +impl From for SkillRuntimeError { + fn from(error: aionui_db::DbError) -> Self { + Self::ReadFailed { + reason: error.to_string(), + } + } +} diff --git a/crates/aionui-skill-runtime/src/lib.rs b/crates/aionui-skill-runtime/src/lib.rs new file mode 100644 index 000000000..c8e75ffee --- /dev/null +++ b/crates/aionui-skill-runtime/src/lib.rs @@ -0,0 +1,28 @@ +//! Channel A: the RUNTIME CONSUMPTION side of skills. +//! +//! An agent that can execute commands reaches this through +//! `aioncore skills list|show|cat`, which is a normal tool call rather than the +//! text-protocol round trip channel B needs. Channel B stays as the fallback for +//! agents that cannot run commands at all (plan mode, read-only, cron), and the +//! agent picks -- we deliberately do not try to predict which, because permission +//! mode is agent-side runtime state no CLI capability query reveals. +//! +//! Deliberately NOT merged with `config skills *` in `aionui-extension`, which +//! lists every importable skill and can write. This domain is read-only and +//! scoped to one conversation's snapshot. Different semantics, different +//! authority; merging them would let a conversation-scoped runtime token reach +//! the installation-wide management surface. +//! +//! It also cannot LIVE in `aionui-extension`: runtime-token validation needs +//! `aionui-ai-agent`, which sits above the extension crate, so the dependency +//! would invert the layering. + +pub mod error; +pub mod routes; +pub mod service; +pub mod state; + +pub use error::SkillRuntimeError; +pub use routes::skill_runtime_routes; +pub use service::SkillRuntimeService; +pub use state::SkillRuntimeRouterState; diff --git a/crates/aionui-skill-runtime/src/routes.rs b/crates/aionui-skill-runtime/src/routes.rs new file mode 100644 index 000000000..5ddddf074 --- /dev/null +++ b/crates/aionui-skill-runtime/src/routes.rs @@ -0,0 +1,153 @@ +// `ApiError` is not used here at all -- this router speaks the agent-facing CLI +// envelope, not the front-end's `ApiResponse`. Kept explicit so a future handler +// does not silently mix the two. +#![allow(clippy::disallowed_types)] + +//! Routes. Request/response transformation only. +//! +//! Every route authenticates on its own runtime-token header rather than going +//! through the user auth middleware, exactly like the `session` runtime routes: +//! the caller is an agent process holding a conversation-scoped token, not a +//! browser session. +//! +//! READ-ONLY BY CONSTRUCTION: only `get` is registered. `config skills *` remains +//! the read-write management surface; letting a runtime token reach it would hand +//! an agent authority over the whole installation's skills. + +use aionui_ai_agent::TEAM_RUNTIME_TOKEN_SESSION_GENERATION; +use aionui_ai_agent::runtime_token::RuntimeTokenScope; +use aionui_api_types::{ + RuntimeSkillFileQuery, RuntimeSkillFileResponse, RuntimeSkillListResponse, RuntimeSkillShowResponse, + SkillRuntimeEnvelope, SkillRuntimeErrorCode, SkillRuntimeErrorPayload, +}; +use axum::Json; +use axum::Router; +use axum::extract::{Path, Query, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::routing::get; + +use crate::error::SkillRuntimeError; +use crate::state::SkillRuntimeRouterState; + +const HEADER_USER_ID: &str = "x-aionui-user-id"; +const HEADER_CONVERSATION_ID: &str = "x-aionui-conversation-id"; +const HEADER_RUNTIME_TOKEN: &str = "x-aionui-runtime-token"; + +pub fn skill_runtime_routes(state: SkillRuntimeRouterState) -> Router { + Router::new() + .route("/api/runtime/skills", get(list)) + .route("/api/runtime/skills/{name}", get(show)) + .route("/api/runtime/skills/{name}/file", get(read_file)) + .with_state(state) +} + +struct RuntimeCaller { + user_id: String, + conversation_id: String, +} + +/// Shared preamble for every route. The token is validated against BOTH the user +/// and the conversation, so a token minted for one conversation cannot read +/// another's skills even within the same user. +fn runtime_caller(state: &SkillRuntimeRouterState, headers: &HeaderMap) -> Result { + let user_id = required_header(headers, HEADER_USER_ID)?; + let conversation_id = required_header(headers, HEADER_CONVERSATION_ID)?; + let token = required_header(headers, HEADER_RUNTIME_TOKEN)?; + state + .runtime_token_service + .validate( + Some(&token), + &user_id, + &conversation_id, + RuntimeTokenScope::ConversationHelper, + // Despite the `TEAM_` prefix this is not team-specific: every + // conversation's helper token is issued with this same constant + // (value "default"), and passing anything else fails validation. + TEAM_RUNTIME_TOKEN_SESSION_GENERATION, + ) + .map_err(|_| SkillRuntimeError::RuntimeAuthFailed)?; + Ok(RuntimeCaller { + user_id, + conversation_id, + }) +} + +fn required_header(headers: &HeaderMap, name: &'static str) -> Result { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .ok_or(SkillRuntimeError::RuntimeAuthFailed) +} + +async fn list( + State(state): State, + headers: HeaderMap, +) -> (StatusCode, Json>) { + let command = Some("skills list".to_owned()); + let caller = match runtime_caller(&state, &headers) { + Ok(caller) => caller, + Err(error) => return envelope_failure(error, command), + }; + match state.service.list(&caller.user_id, &caller.conversation_id).await { + Ok(data) => (StatusCode::OK, Json(SkillRuntimeEnvelope::success(data, command))), + Err(error) => envelope_failure(error, command), + } +} + +async fn show( + State(state): State, + headers: HeaderMap, + Path(name): Path, +) -> (StatusCode, Json>) { + let command = Some("skills show".to_owned()); + let caller = match runtime_caller(&state, &headers) { + Ok(caller) => caller, + Err(error) => return envelope_failure(error, command), + }; + match state + .service + .show(&caller.user_id, &caller.conversation_id, &name) + .await + { + Ok(data) => (StatusCode::OK, Json(SkillRuntimeEnvelope::success(data, command))), + Err(error) => envelope_failure(error, command), + } +} + +async fn read_file( + State(state): State, + headers: HeaderMap, + Path(name): Path, + Query(query): Query, +) -> (StatusCode, Json>) { + let command = Some("skills cat".to_owned()); + let caller = match runtime_caller(&state, &headers) { + Ok(caller) => caller, + Err(error) => return envelope_failure(error, command), + }; + match state + .service + .read_file(&caller.user_id, &caller.conversation_id, &name, &query.path) + .await + { + Ok(data) => (StatusCode::OK, Json(SkillRuntimeEnvelope::success(data, command))), + Err(error) => envelope_failure(error, command), + } +} + +fn envelope_failure( + error: SkillRuntimeError, + command: Option, +) -> (StatusCode, Json>) { + let status = StatusCode::from_u16(error.http_status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + // The message is the crate error's Display, which never carries a resolved + // filesystem path -- only the requested name / relative shape. + let payload = SkillRuntimeErrorPayload::new(error.code(), error.to_string()); + debug_assert!( + payload.code != SkillRuntimeErrorCode::TransportUnavailable || status == StatusCode::INTERNAL_SERVER_ERROR + ); + (status, Json(SkillRuntimeEnvelope::failure(payload, command))) +} diff --git a/crates/aionui-skill-runtime/src/service.rs b/crates/aionui-skill-runtime/src/service.rs new file mode 100644 index 000000000..ea5f586a3 --- /dev/null +++ b/crates/aionui-skill-runtime/src/service.rs @@ -0,0 +1,347 @@ +//! Business logic for the runtime skills domain. No axum here. +//! +//! Every read starts from the conversation's own `extra.skills` snapshot. That +//! filter is the security boundary of this crate, not a convenience: without it +//! a conversation-scoped runtime token could read any skill on the installation, +//! including another assistant's and — on a multi-user Core — another user's. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use aionui_api_types::{ + RuntimeSkillFileResponse, RuntimeSkillListItem, RuntimeSkillListResponse, RuntimeSkillShowResponse, +}; +use aionui_db::{IConversationRepository, ISkillRepository}; +use aionui_extension::SkillPaths; +use tracing::{info, warn}; + +use crate::error::SkillRuntimeError; + +pub struct SkillRuntimeService { + conversation_repo: Arc, + skill_paths: Arc, + skill_repo: Arc, +} + +impl SkillRuntimeService { + pub fn new( + conversation_repo: Arc, + skill_paths: Arc, + skill_repo: Arc, + ) -> Self { + Self { + conversation_repo, + skill_paths, + skill_repo, + } + } + + /// The allow-list: names in this conversation's `extra.skills` snapshot. + async fn enabled_skill_names( + &self, + user_id: &str, + conversation_id: &str, + ) -> Result, SkillRuntimeError> { + let row = self + .conversation_repo + .get(user_id, conversation_id) + .await? + .ok_or(SkillRuntimeError::ConversationNotFound)?; + let extra: serde_json::Value = serde_json::from_str(&row.extra).unwrap_or(serde_json::Value::Null); + Ok(extra + .get("skills") + .and_then(|value| serde_json::from_value::>(value.clone()).ok()) + .unwrap_or_default()) + } + + /// `skills list` — only what this conversation enabled. + /// + /// Descriptions are read from the same on-disk `SKILL.md` that `show` reads, + /// NOT from the DB catalog. That keeps the two commands consistent by + /// construction: a catalog read would make `list` depend on whether a startup + /// sync has happened, so a freshly imported skill could be invisible to + /// `list` while `show` served it happily. + pub async fn list( + &self, + user_id: &str, + conversation_id: &str, + ) -> Result { + let enabled = self.enabled_skill_names(user_id, conversation_id).await?; + if enabled.is_empty() { + return Ok(RuntimeSkillListResponse { skills: Vec::new() }); + } + + let resolved = aionui_extension::materialize_skills_for_agent_with_repo_for_user( + &self.skill_paths, + self.skill_repo.as_ref(), + user_id, + conversation_id, + &enabled, + ) + .await + .map_err(|e| SkillRuntimeError::ReadFailed { reason: e.to_string() })?; + + let mut skills: Vec = Vec::with_capacity(resolved.len()); + for skill in resolved { + // An unreadable or malformed SKILL.md must not cost the agent the + // whole listing -- it would lose access to every OTHER skill too. + let description = match tokio::fs::read_to_string(skill.source_path.join("SKILL.md")).await { + Ok(content) => aionui_extension::skill_service::parse_frontmatter_fields(&content) + .map(|(_, description)| description) + .unwrap_or_default(), + Err(e) => { + warn!( + conversation_id = %conversation_id, + skill = %skill.name, + error = %e, + "runtime skill listing: SKILL.md unreadable; listing it without a description" + ); + String::new() + } + }; + skills.push(RuntimeSkillListItem { + name: skill.name, + description, + }); + } + // Sorted so repeated calls in one conversation do not reshuffle, which + // would make the agent's own context churn for no reason. + skills.sort_by(|a, b| a.name.cmp(&b.name)); + + info!( + conversation_id = %conversation_id, + channel = "skills_cli", + command = "list", + skills = skills.len(), + "runtime skill request served" + ); + Ok(RuntimeSkillListResponse { skills }) + } + + /// Resolve `name` to its source dir, but ONLY if this conversation enabled it. + async fn resolve_enabled( + &self, + user_id: &str, + conversation_id: &str, + name: &str, + ) -> Result { + let enabled = self.enabled_skill_names(user_id, conversation_id).await?; + if !enabled.iter().any(|candidate| candidate == name) { + warn!( + conversation_id = %conversation_id, + channel = "skills_cli", + skill = %name, + "runtime skill request refused: not in this conversation's snapshot" + ); + return Err(SkillRuntimeError::SkillNotEnabled { name: name.to_owned() }); + } + + // Same resolution the view directory uses, and scoped to this user, so a + // same-named skill owned by another user cannot resolve here. + let resolved = aionui_extension::materialize_skills_for_agent_with_repo_for_user( + &self.skill_paths, + self.skill_repo.as_ref(), + user_id, + conversation_id, + std::slice::from_ref(&name.to_owned()), + ) + .await + .map_err(|e| SkillRuntimeError::ReadFailed { reason: e.to_string() })?; + + resolved + .into_iter() + .next() + .map(|skill| skill.source_path) + .ok_or_else(|| SkillRuntimeError::SkillNotFound { name: name.to_owned() }) + } + + /// `skills show ` — body plus the absolute root. + pub async fn show( + &self, + user_id: &str, + conversation_id: &str, + name: &str, + ) -> Result { + let root = self.resolve_enabled(user_id, conversation_id, name).await?; + let content = tokio::fs::read_to_string(root.join("SKILL.md")) + .await + .map_err(|e| SkillRuntimeError::ReadFailed { reason: e.to_string() })?; + + info!( + conversation_id = %conversation_id, + channel = "skills_cli", + command = "show", + skill = %name, + "runtime skill request served" + ); + Ok(RuntimeSkillShowResponse { + name: name.to_owned(), + // Shared with the `[LOAD_SKILL]` channel so both return identical + // bodies; a local copy is how the two would drift. + body: aionui_extension::skill_service::extract_skill_body(&content), + path: root.to_string_lossy().into_owned(), + }) + } + + /// `skills cat /` — a supplementary file. + pub async fn read_file( + &self, + user_id: &str, + conversation_id: &str, + name: &str, + rel_path: &str, + ) -> Result { + let root = self.resolve_enabled(user_id, conversation_id, name).await?; + let target = resolve_inside(&root, rel_path).inspect_err(|_| { + // The REQUESTED relative shape only. Logging the resolved absolute + // path would put the escape target in the log, which is exactly the + // thing an attacker wanted to learn. + warn!( + conversation_id = %conversation_id, + channel = "skills_cli", + skill = %name, + requested = %rel_path, + "runtime skill file request refused: path escapes the skill directory" + ); + })?; + + let content = tokio::fs::read_to_string(&target) + .await + .map_err(|e| SkillRuntimeError::ReadFailed { reason: e.to_string() })?; + + info!( + conversation_id = %conversation_id, + channel = "skills_cli", + command = "cat", + skill = %name, + "runtime skill request served" + ); + Ok(RuntimeSkillFileResponse { + name: name.to_owned(), + path: rel_path.to_owned(), + content, + }) + } +} + +/// Traversal guard. +/// +/// Rejects absolute paths and `..` components up front, then canonicalizes and +/// re-checks containment. The second step is the one that matters: a SYMLINK +/// inside the skill directory pointing outward is traversal by another name, and +/// no amount of lexical checking catches it. +/// +/// The root is canonicalized too — the skill directory is itself reached through +/// a symlink in some layouts, so comparing a canonical target against a +/// non-canonical root would reject every legitimate read. +fn resolve_inside(root: &Path, rel: &str) -> Result { + let rel = rel.trim(); + if rel.is_empty() { + return Err(SkillRuntimeError::InvalidPath { + reason: "path must not be empty".to_owned(), + }); + } + let candidate = Path::new(rel); + if candidate.is_absolute() + || candidate.components().any(|component| { + matches!( + component, + std::path::Component::ParentDir | std::path::Component::Prefix(_) | std::path::Component::RootDir + ) + }) + { + return Err(SkillRuntimeError::InvalidPath { + reason: "path must be relative and must not contain '..'".to_owned(), + }); + } + + let canonical_root = root.canonicalize().map_err(|e| SkillRuntimeError::ReadFailed { + reason: format!("skill directory unreadable: {e}"), + })?; + let canonical_target = + canonical_root + .join(candidate) + .canonicalize() + .map_err(|e| SkillRuntimeError::InvalidPath { + reason: format!("path does not resolve inside the skill: {e}"), + })?; + if !canonical_target.starts_with(&canonical_root) { + return Err(SkillRuntimeError::InvalidPath { + reason: "path resolves outside the skill directory".to_owned(), + }); + } + Ok(canonical_target) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn skill_dir() -> (tempfile::TempDir, PathBuf) { + let tmp = tempfile::TempDir::new().unwrap(); + let root = tmp.path().join("cron"); + std::fs::create_dir_all(root.join("references")).unwrap(); + std::fs::write(root.join("SKILL.md"), "---\nname: cron\n---\nBody").unwrap(); + std::fs::write(root.join("references").join("notes.md"), "REFTOKEN").unwrap(); + (tmp, root) + } + + #[test] + fn a_plain_relative_file_resolves() { + let (_tmp, root) = skill_dir(); + let target = resolve_inside(&root, "references/notes.md").unwrap(); + assert_eq!(std::fs::read_to_string(target).unwrap(), "REFTOKEN"); + } + + #[test] + fn every_traversal_shape_is_refused() { + let (_tmp, root) = skill_dir(); + for bad in [ + "../../../.ssh/id_rsa", + "references/../../escape.md", + "/etc/passwd", + "references/../../../etc/passwd", + "..", + "", + " ", + ] { + assert!( + matches!(resolve_inside(&root, bad), Err(SkillRuntimeError::InvalidPath { .. })), + "path {bad:?} must be refused as InvalidPath" + ); + } + } + + /// The case lexical checking cannot catch: nothing about `escape.md` looks + /// suspicious, yet it leaves the skill directory. + #[cfg(unix)] + #[test] + fn a_symlink_out_of_the_skill_directory_is_refused() { + let (tmp, root) = skill_dir(); + let outside = tmp.path().join("outside-secret.md"); + std::fs::write(&outside, "OUTSIDE").unwrap(); + std::os::unix::fs::symlink(&outside, root.join("escape.md")).unwrap(); + + assert!( + matches!( + resolve_inside(&root, "escape.md"), + Err(SkillRuntimeError::InvalidPath { .. }) + ), + "a symlink whose canonical target leaves the skill dir must be refused" + ); + } + + /// A skill directory reached THROUGH a symlink must still be readable -- the + /// view directory is built exactly that way, so canonicalizing only the + /// target would reject every legitimate read. + #[cfg(unix)] + #[test] + fn a_symlinked_skill_root_still_reads() { + let (tmp, real_root) = skill_dir(); + let linked_root = tmp.path().join("linked-cron"); + std::os::unix::fs::symlink(&real_root, &linked_root).unwrap(); + + let target = resolve_inside(&linked_root, "references/notes.md").unwrap(); + assert_eq!(std::fs::read_to_string(target).unwrap(), "REFTOKEN"); + } +} diff --git a/crates/aionui-skill-runtime/src/state.rs b/crates/aionui-skill-runtime/src/state.rs new file mode 100644 index 000000000..c46feed79 --- /dev/null +++ b/crates/aionui-skill-runtime/src/state.rs @@ -0,0 +1,13 @@ +use std::sync::Arc; + +use aionui_ai_agent::runtime_token::RuntimeTokenService; + +use crate::service::SkillRuntimeService; + +/// Router state. Arc-wrapped dependencies only, constructed in `aionui-app`'s +/// `build_skill_runtime_state()` per the dependency-injection convention. +#[derive(Clone)] +pub struct SkillRuntimeRouterState { + pub service: Arc, + pub runtime_token_service: Arc, +} diff --git a/crates/aionui-skill-runtime/tests/common/mod.rs b/crates/aionui-skill-runtime/tests/common/mod.rs new file mode 100644 index 000000000..a95b41388 --- /dev/null +++ b/crates/aionui-skill-runtime/tests/common/mod.rs @@ -0,0 +1,228 @@ +//! Harness for the runtime skills domain. +//! +//! Real in-memory DB, real skill files on disk, real router — because everything +//! this crate does is "read what the snapshot allows", and a mocked repository +//! would let the allow-list pass by construction rather than by behaviour. +//! +//! Conversation rows are inserted directly instead of going through +//! `ConversationService`: this crate never writes them, and the heavier harness +//! would only add ways for a test to pass for the wrong reason. + +#![allow(dead_code)] + +use std::sync::Arc; + +use aionui_ai_agent::runtime_token::{RuntimeTokenScope, RuntimeTokenService}; +use aionui_api_types::SKILL_RUNTIME_SCHEMA_VERSION; +use aionui_db::models::ConversationRow; +use aionui_db::{ + IConversationRepository, ISkillRepository, SqliteConversationRepository, SqliteSkillRepository, + init_database_memory, +}; +use aionui_extension::SkillPaths; +use aionui_skill_runtime::{SkillRuntimeRouterState, SkillRuntimeService, skill_runtime_routes}; +use axum::Router; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use tower::ServiceExt; + +pub const SESSION_GENERATION: &str = "default"; + +pub struct TestHarness { + _tmp: tempfile::TempDir, + paths: Arc, + pool: sqlx::SqlitePool, + conversation_repo: Arc, + token_service: Arc, + router: Router, + next_id: std::sync::atomic::AtomicUsize, +} + +impl TestHarness { + pub async fn new() -> Self { + let tmp = tempfile::TempDir::new().unwrap(); + let data_dir = tmp.path().to_path_buf(); + let paths = Arc::new(SkillPaths { + data_dir: data_dir.clone(), + user_skills_dir: data_dir.join("skills"), + cron_skills_dir: data_dir.join("cron").join("skills"), + builtin_skills_dir: data_dir.join("builtin-skills"), + builtin_rules_dir: data_dir.join("builtin-rules"), + assistant_rules_dir: data_dir.join("assistant-rules"), + assistant_skills_dir: data_dir.join("assistant-skills"), + }); + std::fs::create_dir_all(&paths.builtin_skills_dir).unwrap(); + std::fs::create_dir_all(&paths.user_skills_dir).unwrap(); + + let db = init_database_memory().await.unwrap(); + let conversation_repo: Arc = + Arc::new(SqliteConversationRepository::new(db.pool().clone())); + let skill_repo: Arc = Arc::new(SqliteSkillRepository::new(db.pool().clone())); + let token_service = Arc::new(RuntimeTokenService::new()); + + let service = Arc::new(SkillRuntimeService::new( + conversation_repo.clone(), + paths.clone(), + skill_repo, + )); + let router = skill_runtime_routes(SkillRuntimeRouterState { + service, + runtime_token_service: token_service.clone(), + }); + + Self { + _tmp: tmp, + paths, + pool: db.pool().clone(), + conversation_repo, + token_service, + router, + next_id: std::sync::atomic::AtomicUsize::new(0), + } + } + + /// `conversations.user_id` is a foreign key, so a test user must exist before + /// its conversations do. Idempotent so each test can name users freely. + async fn ensure_user(&self, user_id: &str) { + sqlx::query( + "INSERT OR IGNORE INTO users \ + (id, user_type, username, password_hash, status, session_generation, created_at, updated_at) \ + VALUES (?, 'local', ?, 'hash', 'active', 0, 1, 1)", + ) + .bind(user_id) + .bind(user_id) + .execute(&self.pool) + .await + .unwrap(); + } + + pub fn data_dir(&self) -> &std::path::Path { + &self.paths.data_dir + } + + /// Write a builtin skill so the catalog can discover it for any user. + pub fn seed_skill(&self, name: &str) -> std::path::PathBuf { + self.seed_skill_with_description(name, &format!("{name} description")) + } + + pub fn seed_skill_with_description(&self, name: &str, description: &str) -> std::path::PathBuf { + let dir = self.paths.builtin_skills_dir.join("auto-inject").join(name); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + dir.join("SKILL.md"), + format!("---\nname: {name}\ndescription: {description}\n---\n{name} body text"), + ) + .unwrap(); + dir + } + + pub fn seed_skill_with_reference(&self, name: &str, rel: &str, content: &str) -> std::path::PathBuf { + let dir = self.seed_skill(name); + let target = dir.join(rel); + std::fs::create_dir_all(target.parent().unwrap()).unwrap(); + std::fs::write(&target, content).unwrap(); + dir + } + + /// Insert a conversation whose `extra.skills` snapshot is `skills`. + pub async fn create_conversation(&self, user_id: &str, skills: &[&str]) -> String { + self.ensure_user(user_id).await; + let n = self.next_id.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let id = format!("conv_{user_id}_{n}"); + let extra = serde_json::json!({ "skills": skills, "backend": "claude" }); + let row = ConversationRow { + id: id.clone(), + user_id: user_id.to_owned(), + name: "test".to_owned(), + r#type: "acp".to_owned(), + extra: extra.to_string(), + model: None, + // NOT NULL in the schema despite the Option in the row model. + status: Some("finished".to_owned()), + source: None, + channel_chat_id: None, + pinned: false, + pinned_at: None, + created_at: 1, + updated_at: 1, + project_id: None, + folder_id: None, + name_source: None, + }; + self.conversation_repo.create(&row).await.unwrap(); + id + } + + fn token(&self, user_id: &str, conversation_id: &str) -> String { + self.token_service + .issue( + user_id, + conversation_id, + SESSION_GENERATION, + [RuntimeTokenScope::ConversationHelper], + ) + .token + } + + async fn send(&self, request: Request) -> (StatusCode, serde_json::Value) { + let response = self.router.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, body) + } + + pub async fn get_raw(&self, user_id: &str, conversation_id: &str, uri: &str) -> (StatusCode, serde_json::Value) { + let request = Request::builder() + .uri(uri) + .header("x-aionui-user-id", user_id) + .header("x-aionui-conversation-id", conversation_id) + .header("x-aionui-runtime-token", self.token(user_id, conversation_id)) + .body(Body::empty()) + .unwrap(); + self.send(request).await + } + + /// Same call, but with a token minted for a DIFFERENT conversation — the + /// shape a compromised or confused agent would produce. + pub async fn get_with_foreign_token( + &self, + user_id: &str, + conversation_id: &str, + token_conversation_id: &str, + uri: &str, + ) -> (StatusCode, serde_json::Value) { + let request = Request::builder() + .uri(uri) + .header("x-aionui-user-id", user_id) + .header("x-aionui-conversation-id", conversation_id) + .header("x-aionui-runtime-token", self.token(user_id, token_conversation_id)) + .body(Body::empty()) + .unwrap(); + self.send(request).await + } + + pub async fn get_raw_without_token( + &self, + user_id: &str, + conversation_id: &str, + uri: &str, + ) -> (StatusCode, serde_json::Value) { + let request = Request::builder() + .uri(uri) + .header("x-aionui-user-id", user_id) + .header("x-aionui-conversation-id", conversation_id) + .body(Body::empty()) + .unwrap(); + self.send(request).await + } + + pub async fn get_json(&self, user_id: &str, conversation_id: &str, uri: &str) -> serde_json::Value { + let (status, body) = self.get_raw(user_id, conversation_id, uri).await; + assert_eq!(status, StatusCode::OK, "expected 200 for {uri}, got {body}"); + assert_eq!(body["success"], true, "{body}"); + assert_eq!(body["meta"]["schema_version"], SKILL_RUNTIME_SCHEMA_VERSION); + body + } +} diff --git a/crates/aionui-skill-runtime/tests/runtime_skills_e2e.rs b/crates/aionui-skill-runtime/tests/runtime_skills_e2e.rs new file mode 100644 index 000000000..9fcbd456d --- /dev/null +++ b/crates/aionui-skill-runtime/tests/runtime_skills_e2e.rs @@ -0,0 +1,239 @@ +//! Channel A end-to-end. +//! +//! This is a NEW surface reachable with a conversation-scoped runtime token, so +//! the tests lead with the three security constraints rather than the happy path: +//! snapshot scoping, cross-conversation refusal, and traversal containment. + +mod common; + +use axum::http::StatusCode; +use common::TestHarness; + +// ── Security ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn list_returns_only_the_skills_in_this_conversations_snapshot() { + let h = TestHarness::new().await; + h.seed_skill("cron"); + h.seed_skill("pdf"); + let conv = h.create_conversation("user_a", &["cron"]).await; + + let body = h.get_json("user_a", &conv, "/api/runtime/skills").await; + let names: Vec<&str> = body["data"]["skills"] + .as_array() + .unwrap() + .iter() + .map(|s| s["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["cron"], "pdf exists on disk but is not enabled here"); +} + +/// Without this filter the agent bypasses the skill allow-list entirely: a +/// conversation-scoped token could read any skill on the installation. +#[tokio::test] +async fn a_skill_enabled_in_another_conversation_is_refused_here() { + let h = TestHarness::new().await; + h.seed_skill("cron"); + h.seed_skill("pdf"); + let conv_a = h.create_conversation("user_a", &["cron"]).await; + let _conv_b = h.create_conversation("user_a", &["pdf"]).await; + + let (status, body) = h.get_raw("user_a", &conv_a, "/api/runtime/skills/pdf").await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "enabled in a sibling conversation is still not enabled here: {body}" + ); + assert_eq!(body["error"]["code"], "skill_not_enabled"); + assert_eq!(body["success"], false); +} + +/// A token minted for conversation B must not read conversation A, even for the +/// same user: the token is validated against the conversation, not just the user. +#[tokio::test] +async fn a_token_for_another_conversation_is_rejected() { + let h = TestHarness::new().await; + h.seed_skill("cron"); + let conv_a = h.create_conversation("user_a", &["cron"]).await; + let conv_b = h.create_conversation("user_a", &["cron"]).await; + + let (status, body) = h + .get_with_foreign_token("user_a", &conv_a, &conv_b, "/api/runtime/skills") + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "{body}"); + assert_eq!(body["error"]["code"], "runtime_auth_failed"); +} + +#[tokio::test] +async fn an_unauthenticated_request_is_rejected() { + let h = TestHarness::new().await; + h.seed_skill("cron"); + let conv = h.create_conversation("user_a", &["cron"]).await; + + let (status, body) = h.get_raw_without_token("user_a", &conv, "/api/runtime/skills").await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(body["error"]["code"], "runtime_auth_failed"); +} + +/// A conversation belonging to someone else must not be readable even with a +/// token that validates for the caller's own id. +#[tokio::test] +async fn another_users_conversation_is_not_found() { + let h = TestHarness::new().await; + h.seed_skill("cron"); + let conv_b = h.create_conversation("user_b", &["cron"]).await; + + let (status, body) = h.get_raw("user_a", &conv_b, "/api/runtime/skills").await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + assert_eq!(body["error"]["code"], "conversation_not_found"); +} + +#[tokio::test] +async fn cat_refuses_every_traversal_shape() { + let h = TestHarness::new().await; + h.seed_skill("cron"); + let conv = h.create_conversation("user_a", &["cron"]).await; + + for bad in [ + "../../../.ssh/id_rsa", + "references/../../escape.md", + "/etc/passwd", + "references/../../../etc/passwd", + "..", + ] { + let uri = format!("/api/runtime/skills/cron/file?path={}", urlencode_for_test(bad)); + let (status, body) = h.get_raw("user_a", &conv, &uri).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "path {bad:?} must be refused: {body}"); + assert_eq!(body["error"]["code"], "invalid_path", "path {bad:?}: {body}"); + } +} + +/// Nothing about `escape.md` looks suspicious, which is exactly why lexical +/// checks are not enough. +#[cfg(unix)] +#[tokio::test] +async fn cat_does_not_follow_a_symlink_out_of_the_skill_directory() { + let h = TestHarness::new().await; + let root = h.seed_skill("cron"); + let outside = h.data_dir().join("outside-secret.md"); + std::fs::write(&outside, "OUTSIDE").unwrap(); + std::os::unix::fs::symlink(&outside, root.join("escape.md")).unwrap(); + let conv = h.create_conversation("user_a", &["cron"]).await; + + let (status, body) = h + .get_raw("user_a", &conv, "/api/runtime/skills/cron/file?path=escape.md") + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert_eq!(body["error"]["code"], "invalid_path"); +} + +/// The error message must not leak where the escape pointed. +#[cfg(unix)] +#[tokio::test] +async fn a_refused_path_does_not_echo_the_resolved_target() { + let h = TestHarness::new().await; + let root = h.seed_skill("cron"); + let outside = h.data_dir().join("outside-secret.md"); + std::fs::write(&outside, "OUTSIDE").unwrap(); + std::os::unix::fs::symlink(&outside, root.join("escape.md")).unwrap(); + let conv = h.create_conversation("user_a", &["cron"]).await; + + let (_status, body) = h + .get_raw("user_a", &conv, "/api/runtime/skills/cron/file?path=escape.md") + .await; + let message = body["error"]["message"].as_str().unwrap_or_default(); + assert!( + !message.contains("outside-secret"), + "the refusal must not name the escape target: {message}" + ); +} + +// ── Functionality ─────────────────────────────────────────────────── + +/// `show` hands back BOTH the body and the absolute root: a read-only agent +/// needs the content, one that can run commands needs the path. +#[tokio::test] +async fn show_returns_the_body_and_the_absolute_skill_root() { + let h = TestHarness::new().await; + let root = h.seed_skill("cron"); + let conv = h.create_conversation("user_a", &["cron"]).await; + + let body = h.get_json("user_a", &conv, "/api/runtime/skills/cron").await; + assert_eq!(body["data"]["name"], "cron"); + let rendered = body["data"]["body"].as_str().unwrap(); + assert!(rendered.contains("cron body text")); + assert!( + !rendered.starts_with("---"), + "frontmatter is stripped, matching the LOAD_SKILL channel: {rendered:?}" + ); + assert_eq!(body["data"]["path"], root.display().to_string()); +} + +#[tokio::test] +async fn cat_reads_a_supplementary_reference_file() { + let h = TestHarness::new().await; + h.seed_skill_with_reference("cron", "references/notes.md", "REFTOKEN-4417"); + let conv = h.create_conversation("user_a", &["cron"]).await; + + let body = h + .get_json( + "user_a", + &conv, + "/api/runtime/skills/cron/file?path=references%2Fnotes.md", + ) + .await; + assert_eq!(body["data"]["content"], "REFTOKEN-4417"); + assert_eq!(body["data"]["path"], "references/notes.md", "echoed for correlation"); +} + +#[tokio::test] +async fn an_empty_snapshot_lists_nothing_rather_than_everything() { + let h = TestHarness::new().await; + h.seed_skill("cron"); + let conv = h.create_conversation("user_a", &[]).await; + + let body = h.get_json("user_a", &conv, "/api/runtime/skills").await; + assert_eq!(body["data"]["skills"].as_array().unwrap().len(), 0); +} + +#[tokio::test] +async fn list_is_sorted_so_repeated_calls_do_not_reshuffle() { + let h = TestHarness::new().await; + for name in ["zeta", "alpha", "middle"] { + h.seed_skill(name); + } + let conv = h.create_conversation("user_a", &["zeta", "alpha", "middle"]).await; + + let body = h.get_json("user_a", &conv, "/api/runtime/skills").await; + let names: Vec<&str> = body["data"]["skills"] + .as_array() + .unwrap() + .iter() + .map(|s| s["name"].as_str().unwrap()) + .collect(); + assert_eq!(names, vec!["alpha", "middle", "zeta"]); +} + +/// A snapshot naming a skill that is not on disk is a broken install, not a +/// permission problem, and the two codes must stay distinguishable. +#[tokio::test] +async fn an_enabled_but_missing_skill_reports_not_found_not_not_enabled() { + let h = TestHarness::new().await; + let conv = h.create_conversation("user_a", &["ghost"]).await; + + let (status, body) = h.get_raw("user_a", &conv, "/api/runtime/skills/ghost").await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + assert_eq!(body["error"]["code"], "skill_not_found"); +} + +/// Minimal percent-encoding for the test's own query values. +fn urlencode_for_test(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(byte as char), + _ => out.push_str(&format!("%{byte:02X}")), + } + } + out +} diff --git a/crates/aionui-team/src/service/spawn_support.rs b/crates/aionui-team/src/service/spawn_support.rs index 3688e3242..fe1466a28 100644 --- a/crates/aionui-team/src/service/spawn_support.rs +++ b/crates/aionui-team/src/service/spawn_support.rs @@ -279,6 +279,7 @@ mod tests { args: None, env: None, native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: yolo_id.map(ToOwned::to_owned), agent_capabilities: None, diff --git a/crates/aionui-team/tests/session_service_integration.rs b/crates/aionui-team/tests/session_service_integration.rs index aa7a5f41f..54a13f206 100644 --- a/crates/aionui-team/tests/session_service_integration.rs +++ b/crates/aionui-team/tests/session_service_integration.rs @@ -2595,6 +2595,7 @@ fn make_agent_metadata_row(id: &str, backend: &str, icon: &str) -> AgentMetadata args: None, env: None, native_skills_dirs: None, + skill_delivery: None, behavior_policy: None, yolo_id: None, agent_capabilities: None,