From 5ed0292805dffdc6e1c5489f9b0fbbdafc5cbeea Mon Sep 17 00:00:00 2001 From: zynx <> Date: Mon, 24 Aug 2026 23:29:26 +0800 Subject: [PATCH 01/13] feat(agent-metadata): add skill_delivery column with tolerant parsing Declares per-vendor skill delivery as DATA rather than code, so a new vendor capability ships without a migration or a release. The column carries no DB CHECK on purpose: it is an open extension point, not a closed state machine, and a CHECK would hard-fail a registry insert carrying a newer mode on an older DB. Validation lives in the application layer and is deliberately tolerant -- an unknown mode warns WITH the actual value and falls back to injected, on a log line distinct from malformed JSON, because 'a newer registry wrote this' and 'this data is damaged' need opposite responses. Seeded values, verified against the builds we actually spawn: - claude 2.1.231: argv (--plugin-dir) plus --add-dir allow-listing. A matched pair under the real default permission mode showed both files read with two --add-dir flags and both refused with none, so repeated allow-listing works and --plugin-dir does not exempt skill dirs from the path check. - codex: protocol (skills/extraRoots/set), per its self-generated schema. - codebuddy: stays injected. The pinned 2.137.1 documents --plugin-dir and accepts it at argv level, but whether it actually surfaces skills is unprobed (needs an authenticated account); declaring argv would switch injection to light, so an inert flag would leave it with no skills at all. - antigravity, opencode: injected, no session-scoped skill flag exists. - every other vendor: NULL, read as injected. --- crates/aionui-ai-agent/src/factory/acp.rs | 2 + .../src/factory/acp_assembler.rs | 1 + .../src/factory/acp_launch_policy.rs | 1 + .../aionui-ai-agent/src/manager/acp/agent.rs | 1 + .../src/manager/acp/mode_normalize.rs | 1 + crates/aionui-ai-agent/src/registry.rs | 43 +++++ crates/aionui-ai-agent/src/registry_tests.rs | 8 + .../src/services/availability/mod.rs | 6 + crates/aionui-ai-agent/src/services/custom.rs | 1 + crates/aionui-ai-agent/src/session_agent.rs | 1 + .../tests/agent_availability_integration.rs | 2 + .../aionui-api-types/src/agent_discovery.rs | 12 ++ crates/aionui-api-types/src/lib.rs | 2 + crates/aionui-api-types/src/skill_delivery.rs | 182 ++++++++++++++++++ crates/aionui-app/tests/acp_e2e.rs | 3 + .../aionui-app/tests/agent_integration_e2e.rs | 3 + crates/aionui-app/tests/assistants_e2e.rs | 1 + crates/aionui-assistant/src/service.rs | 1 + .../aionui-conversation/src/service_test.rs | 2 + .../src/session_context.rs | 1 + .../aionui-cron/tests/service_integration.rs | 1 + .../migrations/042_agent_skill_delivery.sql | 108 +++++++++++ crates/aionui-db/src/models/agent_metadata.rs | 13 +- .../src/repository/sqlite_agent_metadata.rs | 13 +- .../aionui-db/tests/agent_binding_resolver.rs | 1 + .../tests/agent_skill_delivery_migration.rs | 126 ++++++++++++ .../aionui-team/src/service/spawn_support.rs | 1 + .../tests/session_service_integration.rs | 1 + 28 files changed, 532 insertions(+), 6 deletions(-) create mode 100644 crates/aionui-api-types/src/skill_delivery.rs create mode 100644 crates/aionui-db/migrations/042_agent_skill_delivery.sql create mode 100644 crates/aionui-db/tests/agent_skill_delivery_migration.rs diff --git a/crates/aionui-ai-agent/src/factory/acp.rs b/crates/aionui-ai-agent/src/factory/acp.rs index c2ff58554..2dfdeba8d 100644 --- a/crates/aionui-ai-agent/src/factory/acp.rs +++ b/crates/aionui-ai-agent/src/factory/acp.rs @@ -703,6 +703,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 +896,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/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/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 1874b1d9c..0b94afce5 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -4717,6 +4717,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, 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-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/lib.rs b/crates/aionui-api-types/src/lib.rs index 8f2c9c0b7..f3c43de3d 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -31,6 +31,7 @@ mod session_tools; mod shell; mod sidebar; mod skill; +mod skill_delivery; mod system; mod team; mod team_mcp; @@ -180,6 +181,7 @@ pub use skill::{ SkillImportRecordResponse, SkillListItemResponse, SkillPathsResponse, SkillSourceResponse, WriteAssistantRuleRequest, }; +pub use skill_delivery::{SkillDelivery, SkillDeliveryMode, SkillDeliveryParse, parse_skill_delivery}; 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-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-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index e4879d7ea..fb91dad6f 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -759,6 +759,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 +920,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, 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-cron/tests/service_integration.rs b/crates/aionui-cron/tests/service_integration.rs index 652c99ce1..4b8fa72df 100644 --- a/crates/aionui-cron/tests/service_integration.rs +++ b/crates/aionui-cron/tests/service_integration.rs @@ -2806,6 +2806,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/042_agent_skill_delivery.sql b/crates/aionui-db/migrations/042_agent_skill_delivery.sql new file mode 100644 index 000000000..95c4025e8 --- /dev/null +++ b/crates/aionui-db/migrations/042_agent_skill_delivery.sql @@ -0,0 +1,108 @@ +-- 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.137.1`, 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`. +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 DOES take `--add-dir` and its own help marks it "(repeatable)", so the +-- allow-listing shape applies. Note agy's argv already passes +-- `--dangerously-skip-permissions` unconditionally (`antigravity/argv.rs`), so +-- this may well be a no-op there; the parameter is idempotent and harmless +-- either way, and the live check is tracked as an acceptance item. +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 = '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/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/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..1eec5b97e --- /dev/null +++ b/crates/aionui-db/tests/agent_skill_delivery_migration.rs @@ -0,0 +1,126 @@ +//! Migration 042 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.137.1) +/// 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"); +} + +#[tokio::test] +async fn antigravity_is_injected_with_allow_dir_args() { + 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!(["--add-dir", "{skill_dir}"]) + ); +} + +#[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-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 7a6458d24..55f92074d 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, From d2899cdfdc41ef4a5ee5baacaba3f54134a4fa80 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Mon, 24 Aug 2026 23:34:34 +0800 Subject: [PATCH 02/13] feat(extension): add AionUi-owned per-conversation skill view directory Moves the symlink landing site out of the user's workspace into {data_dir}/session-skills/{user_id}/{conversation_id}/. One tree serves both vendor shapes: the plugin root carries .claude-plugin/plugin.json for --plugin-dir, and skills/ underneath is the skills root codex extraRoots wants. Both were probed against this exact layout, including with the skill directory as a symlink. Owning the tree changes two things versus the workspace path it replaces: rebuild is an exact match against the snapshot, so a skill dropped from extra.skills actually disappears (first-write-wins could not do that), and deletion is safe. Removing the tree unlinks our symlinks and never touches the real skill sources, which a test pins. No copy fallback: a failed link is skipped with a warn rather than degrading to a recursive copy of real files, as the old workspace path did. plugin.json's name is 'aionui' and is deliberately user-visible -- a plugin name becomes the prefix of every skill name the agent sees, so extra.skills' bare 'cron' is presented as 'aionui:cron'. Callers must not assume the two sides match when correlating by name. Orphan cleanup is keyed by the (user, conversation) PAIR, not the conversation alone, so one user's missing conversation cannot reap another user's view. --- crates/aionui-extension/src/lib.rs | 1 + crates/aionui-extension/src/skill_service.rs | 4 +- crates/aionui-extension/src/skill_view.rs | 437 +++++++++++++++++++ 3 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 crates/aionui-extension/src/skill_view.rs diff --git a/crates/aionui-extension/src/lib.rs b/crates/aionui-extension/src/lib.rs index 1544fc208..9b1f0ffad 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; diff --git a/crates/aionui-extension/src/skill_service.rs b/crates/aionui-extension/src/skill_service.rs index 3d3980b84..57a71ebb9 100644 --- a/crates/aionui-extension/src/skill_service.rs +++ b/crates/aionui-extension/src/skill_service.rs @@ -2293,12 +2293,12 @@ mod test_overrides { /// 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. diff --git a/crates/aionui-extension/src/skill_view.rs b/crates/aionui-extension/src/skill_view.rs new file mode 100644 index 000000000..777693e94 --- /dev/null +++ b/crates/aionui-extension/src/skill_view.rs @@ -0,0 +1,437 @@ +//! 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::HashSet; +use std::path::{Path, PathBuf}; + +use tracing::{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() +} + +/// Rebuild the view so it matches `skills` EXACTLY. Returns the number of links +/// created. Idempotent: build-task calls this on every session open. +pub async fn rebuild_view( + data_dir: &Path, + user_id: &str, + conversation_id: &str, + skills: &[ResolvedAgentSkill], +) -> Result { + let view = view_dir(data_dir, user_id, conversation_id)?; + let skills_dir = view.join(SKILLS_SUBDIR); + + 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?; + + // 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 skills { + if !tokio::fs::try_exists(&skill.source_path).await.unwrap_or(false) { + warn!( + skill = %skill.name, + conversation_id = %conversation_id, + "skill_view: source directory missing; skipping this skill" + ); + continue; + } + 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" + ), + } + } + + info!( + user_id = %user_id, + conversation_id = %conversation_id, + skills = created, + requested = skills.len(), + "skill_view: rebuilt session skill view" + ); + Ok(created) +} + +/// 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"]); + } + + #[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); + } +} From baa42495a058c4edca1b517fa1b2c25147057505 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Mon, 24 Aug 2026 23:49:24 +0800 Subject: [PATCH 03/13] feat(skills): build a per-conversation skill view alongside workspace links Additive on purpose: the view directory starts being built and torn down, while workspace linking keeps working. Nothing loses skills in this state, which is what makes removing the workspace path a separate, revertible step. The view is synced for EVERY agent, not only vendors that consume it, so flipping a delivery mode in the registry -- a data-only change -- needs no per-conversation backfill. An empty snapshot syncs nothing: registering an empty plugin root would still cost the agent an always-on token line. Lifecycle: create builds it, build-task re-syncs it idempotently (so a view deleted out-of-band, or never built on an older conversation, comes back), and delete drops it BEFORE the row disappears -- afterwards nothing knows the (user, conversation) pair and the view would wait for a startup sweep. skill_delivery_plan is a pure function so the substitution rules are testable in isolation: they are the part most likely to go subtly wrong (wrong root handed to the wrong vendor, an allow-list collapsing to one entry). Backends only ever see already-substituted strings. A leftover placeholder from a newer registry is kept verbatim and reported rather than dropped, because dropping a flag silently changes the spawn. sync_skill_view/remove_skill_view default to no-ops on the trait so the nine existing test stubs need no bodies; the default cannot hide a production regression because the real implementation has its own test driving it against a temp data dir. --- crates/aionui-ai-agent/src/lib.rs | 1 + crates/aionui-ai-agent/src/session_agent.rs | 7 + .../src/skill_delivery_plan.rs | 289 ++++++++++++++++++ crates/aionui-conversation/src/service.rs | 51 ++++ .../aionui-conversation/src/service_test.rs | 164 ++++++++-- .../aionui-conversation/src/skill_resolver.rs | 92 ++++++ crates/aionui-session/src/backend/mod.rs | 34 ++- crates/aionui-session/src/lib.rs | 2 +- 8 files changed, 613 insertions(+), 27 deletions(-) create mode 100644 crates/aionui-ai-agent/src/skill_delivery_plan.rs 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/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index 0b94afce5..ce4f82ab4 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -1729,6 +1729,10 @@ pub async fn build_antigravity_instance( mcp_servers, skills: config.skills.clone(), preset_context: config.preset_context.clone(), + // agy is a layer-2 vendor: no protocol root, and its skill dirs are + // wired in with its injection path. + skill_view_skills_dir: None, + skill_dirs: Vec::new(), session_snapshot: None, resume: matches!(spec, aionui_session::SessionSpec::Resume { .. }), }; @@ -1855,6 +1859,9 @@ pub async fn build_session_instance( mcp_servers, skills: config.skills.clone(), preset_context: config.preset_context.clone(), + // Layer-1 delivery paths, wired in with the per-vendor delivery plan. + skill_view_skills_dir: None, + skill_dirs: Vec::new(), // acp/codex resume via SessionSpec::Resume; no in-band snapshot needed. session_snapshot: None, resume: matches!(spec, SessionSpec::Resume { .. }), 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..a42c2f41c --- /dev/null +++ b/crates/aionui-ai-agent/src/skill_delivery_plan.rs @@ -0,0 +1,289 @@ +//! 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, +} + +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-conversation/src/service.rs b/crates/aionui-conversation/src/service.rs index 5cd4aebb0..07b7dfe1b 100644 --- a/crates/aionui-conversation/src/service.rs +++ b/crates/aionui-conversation/src/service.rs @@ -1324,6 +1324,22 @@ impl ConversationService { } } + // Build the per-conversation skill VIEW under AionUi's own data dir. + // + // Unconditional on the delivery mode, unlike the workspace wiring above: + // 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() { + self.skill_resolver.sync_skill_view(user_id, &id, &resolved).await; + } + } + if let Some(obj) = extra.as_object_mut() { obj.insert( "skills".to_owned(), @@ -2628,6 +2644,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()); @@ -4854,6 +4875,13 @@ impl ConversationService { let context = &build_opts.context; let backend = context_backend_value(context); + // Re-sync the skill VIEW first, and unconditionally: build-task runs on + // every session open, so this is where a view deleted out-of-band (or + // never built, on a conversation created before this feature) comes back. + // Deliberately outside the auto-workspace guard below -- that guard is + // about writing into the workspace, which the view does not do. + self.ensure_session_skill_view(context).await; + let workspace = PathBuf::from(context.workspace.path.trim()); if !context.workspace.is_custom { let expected_workspace = expected_auto_workspace_path( @@ -4909,6 +4937,29 @@ impl ConversationService { ); } + /// 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 resolved = self + .skill_resolver + .resolve_skills_for_user(&context.conversation.user_id, &skill_names) + .await; + if resolved.is_empty() { + return; + } + self.skill_resolver + .sync_skill_view(&context.conversation.user_id, context.conversation_id(), &resolved) + .await; + } + /// Write the resolved workspace back to `conversation.extra.workspace` when /// the factory picked a different (auto-generated) path than what was stored. /// diff --git a/crates/aionui-conversation/src/service_test.rs b/crates/aionui-conversation/src/service_test.rs index fb91dad6f..5379b3d67 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -64,9 +64,17 @@ struct SkillLinkCall { skill_names: 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 { @@ -117,6 +125,8 @@ impl RecordingSkillResolver { Self { names, links: Arc::new(Mutex::new(Vec::new())), + views: Arc::new(Mutex::new(Vec::new())), + view_removals: Arc::new(Mutex::new(Vec::new())), } } } @@ -158,6 +168,22 @@ impl SkillResolver for RecordingSkillResolver { } linked } + + 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() + } + + 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())); + } } // ── Mock EventBroadcaster ────────────────────────────────────────── @@ -8675,6 +8701,119 @@ async fn warmup_restores_skill_links_for_custom_workspace() { assert_eq!(calls[0].skill_names, vec!["cron"]); } +/// 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(); + + 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(); + + let calls = views.lock().unwrap(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].conversation_id, resp.id); + assert_eq!(calls[0].skill_names, vec!["cron"]); +} + #[tokio::test] async fn update_rejects_extra_skills() { let (svc, _broadcaster, _repo, task_mgr) = make_service(); @@ -9216,10 +9355,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(), ); @@ -9254,10 +9390,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(), ); @@ -9285,10 +9418,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(), ); @@ -9317,10 +9447,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(), ); @@ -9339,10 +9466,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/skill_resolver.rs b/crates/aionui-conversation/src/skill_resolver.rs index 30a731249..c97e4bdf2 100644 --- a/crates/aionui-conversation/src/skill_resolver.rs +++ b/crates/aionui-conversation/src/skill_resolver.rs @@ -49,6 +49,27 @@ pub trait SkillResolver: Send + Sync { /// 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`. @@ -175,6 +196,37 @@ impl SkillResolver for ExtensionSkillResolver { } } } + + 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) => { + // `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, + "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)] @@ -224,6 +276,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-session/src/backend/mod.rs b/crates/aionui-session/src/backend/mod.rs index 3e4cf5e67..ddff06aba 100644 --- a/crates/aionui-session/src/backend/mod.rs +++ b/crates/aionui-session/src/backend/mod.rs @@ -194,6 +194,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, @@ -207,14 +218,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, From 6d4b358cc81e32fc88d5429c9a216b6a4560e992 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Mon, 24 Aug 2026 23:59:14 +0800 Subject: [PATCH 04/13] feat(skills): deliver the skill view through per-vendor launch args Adds resolve_skill_delivery as the single place every factory branch resolves delivery: which mode applies, which paths get substituted, what gets logged. Splitting that per branch is how the old native_skills_dirs logic drifted between the create path and the build path. claude's layer-1 flags travel via SessionConfig.extra_args rather than being hard-coded in build_claude_init_args, which makes its delivery data-driven like every ACP vendor's: a flag change becomes a registry row instead of a code change. Verified with a live paired probe against claude 2.1.231 that repeating --add-dir works despite the flag being declared variadic () -- two flags read both out-of-cwd files, none refused both -- and pinned on our side by a test that prepend_args does not de-duplicate. The ACP lane gets the same args appended after the launch policy, so a newly probed vendor needs one config row and no code. resolve_skill_dirs_for_user routes through the same materialize_skills_for_agent resolution the conversation service uses for the view, so the directories a CLI gets allow-listed and the directories the view links to agree by construction rather than by coincidence. The delivery decision is logged at info, not debug: it is the anchor for 'why did this vendor not pick up native skills' in a default-level production log. Paths are counted, never listed -- a full allow-list would put user directory names in the log. --- .../src/capability/skill_manager/mod.rs | 54 ++++++++++++++ crates/aionui-ai-agent/src/factory/acp.rs | 27 +++++++ .../src/factory/antigravity.rs | 10 +++ crates/aionui-ai-agent/src/factory/mod.rs | 70 +++++++++++++++++++ crates/aionui-ai-agent/src/session_agent.rs | 39 +++++++++-- .../src/skill_delivery_plan.rs | 13 ++++ .../aionui-session/src/backend/claude_conn.rs | 52 ++++++++++++++ 7 files changed, 259 insertions(+), 6 deletions(-) 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/factory/acp.rs b/crates/aionui-ai-agent/src/factory/acp.rs index 2dfdeba8d..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 diff --git a/crates/aionui-ai-agent/src/factory/antigravity.rs b/crates/aionui-ai-agent/src/factory/antigravity.rs index e0df5e991..92e9954a6 100644 --- a/crates/aionui-ai-agent/src/factory/antigravity.rs +++ b/crates/aionui-ai-agent/src/factory/antigravity.rs @@ -123,6 +123,15 @@ pub(super) async fn build( let mut runtime_env = ctx.runtime_env.clone(); runtime_env.extend(hook_env); + 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_antigravity_instance( crate::session_agent::SessionBuildInputs { conversation_id: ctx.conversation_id.clone(), @@ -130,6 +139,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..b5a2939fc 100644 --- a/crates/aionui-ai-agent/src/factory/mod.rs +++ b/crates/aionui-ai-agent/src/factory/mod.rs @@ -73,6 +73,76 @@ 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, +} + +/// 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 } +} + 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/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index ce4f82ab4..63f07aa3e 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 @@ -1686,6 +1691,7 @@ pub async fn build_antigravity_instance( workspace, config, metadata, + skill_delivery, session_snapshot, backend_session_id, mcp_server_repo, @@ -1729,10 +1735,11 @@ pub async fn build_antigravity_instance( mcp_servers, skills: config.skills.clone(), preset_context: config.preset_context.clone(), - // agy is a layer-2 vendor: no protocol root, and its skill dirs are - // wired in with its injection path. + // 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: Vec::new(), + skill_dirs: skill_delivery.skill_dirs.clone(), session_snapshot: None, resume: matches!(spec, aionui_session::SessionSpec::Resume { .. }), }; @@ -1747,6 +1754,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); @@ -1802,6 +1812,7 @@ pub async fn build_session_instance( workspace, config, metadata, + skill_delivery, session_snapshot, backend_session_id, mcp_server_repo, @@ -1859,9 +1870,11 @@ pub async fn build_session_instance( mcp_servers, skills: config.skills.clone(), preset_context: config.preset_context.clone(), - // Layer-1 delivery paths, wired in with the per-vendor delivery plan. - skill_view_skills_dir: None, - skill_dirs: Vec::new(), + // 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 { .. }), @@ -1883,6 +1896,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() }; @@ -4934,6 +4958,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 index a42c2f41c..235d68464 100644 --- a/crates/aionui-ai-agent/src/skill_delivery_plan.rs +++ b/crates/aionui-ai-agent/src/skill_delivery_plan.rs @@ -37,6 +37,19 @@ pub struct SkillDeliveryPlan { 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(); diff --git a/crates/aionui-session/src/backend/claude_conn.rs b/crates/aionui-session/src/backend/claude_conn.rs index 6944a0163..d69c96f09 100644 --- a/crates/aionui-session/src/backend/claude_conn.rs +++ b/crates/aionui-session/src/backend/claude_conn.rs @@ -3717,6 +3717,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] From eacbe29028b298b01968b5ea3a30600132b0bcd0 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 25 Aug 2026 00:05:21 +0800 Subject: [PATCH 05/13] feat(codex): register the session skill view via skills/extraRoots/set Verified against codex-cli 0.146.0 in two ways this change relies on. Wire shape, from the binary's own generated schema (codex app-server generate-json-schema, v2/SkillsExtraRootsSetParams.json): exactly one required extraRoots array of absolute paths, nothing else. Behaviour, from a live codex app-server --stdio probe: the request answers {}, codex pushes skills/changed, and skills/list reports the skill with errors: []. Two properties that probe pinned are load-bearing -- a SYMLINKED skill directory is discovered (which the whole view design depends on), and path comes back as the REAL source path, so a canonical-path check would not match a view entry. That is why allow-listing targets real source dirs instead. R2' is written into the code as a hard constraint. extraRoots is process-scoped, confirmed twice over: the params carry no threadId (ThreadForkParams and friends do), and the probe reported the skill with scope: user. Isolation holds only because this file opens one process per logical session, so codex layer-1 delivery and thread multiplexing are mutually exclusive -- enabling reuse first requires switching to per-skill skills/config/write or dropping codex to injected, with a cross-conversation isolation test in the same change. Sending is fire-and-forget: the response is an empty object so there is nothing to claim, and a rejection must not fail the session because the dual channel still covers skills. --- .../aionui-session/src/backend/codex_conn.rs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) 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 From 1a2b3fe65ea97e21432788dd5b43a300a2e7d666 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 25 Aug 2026 00:11:02 +0800 Subject: [PATCH 06/13] fix(skills): honour LOAD_SKILL from thinking blocks and anchor bodies to the skill root Two independent defects on the fallback channel, both of which fail silently. P1: detection ran on the think-stripped text, so a request written inside -- where reasoning models routinely put this kind of meta-decision -- disappeared entirely, with no log line anywhere. It presented as 'the model said it would use a skill, then nothing happened'. Detection now reads the RAW message while display keeps reading the stripped text; the two have different purposes and must not share one value. A request is now logged at info with the same structured fields as the command channel, including whether it came from a thinking block, so the split between the channels is measurable. P2: the injected block was '[Skill: name]\n' with no path, so the body's own relative references (references/*.md, scripts/*.py -- skill-creator alone has three) resolved against the agent's CWD, the workspace. The harmless outcome is a missing file; the dangerous one is a workspace that happens to hold a same-named file, which the agent then reads believing it read the skill's. The skill root now leads the block. An integration test builds exactly that collision -- a decoy references/workflows.md in the workspace -- and asserts the injected text points at the skill and never at the workspace. P2 stands on its own, ahead of any directory allow-listing: even where the agent cannot yet read the stated path, naming it downgrades 'silently reads the wrong user file' to 'visibly cannot read it'. --- .../src/response_middleware.rs | 131 +++++++++++++++--- .../aionui-conversation/src/skill_resolver.rs | 9 ++ .../aionui-conversation/src/stream_relay.rs | 1 + .../tests/skill_load_protocol.rs | 90 ++++++++++++ 4 files changed, 212 insertions(+), 19 deletions(-) create mode 100644 crates/aionui-conversation/tests/skill_load_protocol.rs 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/skill_resolver.rs b/crates/aionui-conversation/src/skill_resolver.rs index c97e4bdf2..160e46dc4 100644 --- a/crates/aionui-conversation/src/skill_resolver.rs +++ b/crates/aionui-conversation/src/skill_resolver.rs @@ -14,6 +14,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] @@ -92,6 +100,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!( diff --git a/crates/aionui-conversation/src/stream_relay.rs b/crates/aionui-conversation/src/stream_relay.rs index 92bbf05ba..d56a82bcc 100644 --- a/crates/aionui-conversation/src/stream_relay.rs +++ b/crates/aionui-conversation/src/stream_relay.rs @@ -1130,6 +1130,7 @@ mod tests { .map(|name| LoadedAgentSkill { name: name.clone(), body: format!("{name} body"), + source_path: std::path::PathBuf::from(format!("/src/{name}")), }) .collect() } 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."); +} From 4644dee0ed0a738343d0a670b31a5c47134b1567 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 25 Aug 2026 10:09:34 +0800 Subject: [PATCH 07/13] feat(skills): add the read-only aioncore skills runtime domain Channel A: an agent that can run commands reads its skills through a normal tool call instead of the [LOAD_SKILL] text-protocol round trip. Channel B stays as the fallback for agents that cannot run commands at all, and the AGENT picks -- we do not try to predict which, because permission mode is agent-side runtime state that no CLI capability query reveals. Lives in its own domain crate rather than in aionui-extension: runtime-token validation needs aionui-ai-agent, which sits above the extension crate, so putting it there would invert the layering. Deliberately NOT merged with 'config skills *' either -- that surface lists every importable skill and can write, while this one is read-only and scoped to one conversation's snapshot. Merging them would let a conversation-scoped token reach installation-wide authority. The snapshot filter is the security boundary, so the tests lead with it rather than with the happy path: a skill enabled in a SIBLING conversation is refused 403 (not 404 -- a 404 invites retrying with variations), another user's conversation is invisible, and a token minted for a different conversation is rejected even for the same user. Traversal containment canonicalizes both sides. Rejecting '..' lexically is not enough: a symlink inside the skill directory pointing outward looks entirely innocent, and a test builds exactly that. The root is canonicalized too, because the view directory reaches skills THROUGH symlinks -- comparing a canonical target against a raw root would reject every legitimate read. A refusal names only the requested relative shape, never the resolved target, which a test pins. 'list' reads descriptions from the same on-disk SKILL.md that 'show' reads rather than from the DB catalog, so the two commands cannot disagree about what exists -- a catalog read would make 'list' depend on whether a startup sync had run. Frontmatter stripping moved into aionui-extension so this domain and the [LOAD_SKILL] channel return byte-identical bodies. Registering 'skills' in the top-level domain index closes the discovery half of the same defect that left 'session' unlisted despite having a full contract: an unlisted domain is invisible to 'aioncore capabilities', which would silently push every agent onto the fallback channel. --- Cargo.toml | 2 + crates/aionui-api-types/src/lib.rs | 6 + crates/aionui-api-types/src/skill_runtime.rs | 142 +++++++ crates/aionui-app/Cargo.toml | 1 + crates/aionui-app/src/cli.rs | 25 ++ .../src/commands/cmd_capabilities.rs | 86 +++++ crates/aionui-app/src/commands/cmd_skills.rs | 324 ++++++++++++++++ crates/aionui-app/src/commands/mod.rs | 3 + .../src/commands/skills_capabilities.rs | 84 +++++ crates/aionui-app/src/main.rs | 1 + crates/aionui-app/src/router/routes.rs | 5 + crates/aionui-app/src/router/state.rs | 18 + .../aionui-conversation/src/skill_resolver.rs | 18 +- crates/aionui-extension/src/skill_service.rs | 32 +- crates/aionui-skill-runtime/Cargo.toml | 25 ++ crates/aionui-skill-runtime/src/error.rs | 66 ++++ crates/aionui-skill-runtime/src/lib.rs | 28 ++ crates/aionui-skill-runtime/src/routes.rs | 153 ++++++++ crates/aionui-skill-runtime/src/service.rs | 347 ++++++++++++++++++ crates/aionui-skill-runtime/src/state.rs | 13 + .../aionui-skill-runtime/tests/common/mod.rs | 228 ++++++++++++ .../tests/runtime_skills_e2e.rs | 239 ++++++++++++ 22 files changed, 1831 insertions(+), 15 deletions(-) create mode 100644 crates/aionui-api-types/src/skill_runtime.rs create mode 100644 crates/aionui-app/src/commands/cmd_skills.rs create mode 100644 crates/aionui-app/src/commands/skills_capabilities.rs create mode 100644 crates/aionui-skill-runtime/Cargo.toml create mode 100644 crates/aionui-skill-runtime/src/error.rs create mode 100644 crates/aionui-skill-runtime/src/lib.rs create mode 100644 crates/aionui-skill-runtime/src/routes.rs create mode 100644 crates/aionui-skill-runtime/src/service.rs create mode 100644 crates/aionui-skill-runtime/src/state.rs create mode 100644 crates/aionui-skill-runtime/tests/common/mod.rs create mode 100644 crates/aionui-skill-runtime/tests/runtime_skills_e2e.rs diff --git a/Cargo.toml b/Cargo.toml index 657836076..a2951b052 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-api-types/src/lib.rs b/crates/aionui-api-types/src/lib.rs index f3c43de3d..73b235b51 100644 --- a/crates/aionui-api-types/src/lib.rs +++ b/crates/aionui-api-types/src/lib.rs @@ -32,6 +32,7 @@ mod shell; mod sidebar; mod skill; mod skill_delivery; +mod skill_runtime; mod system; mod team; mod team_mcp; @@ -182,6 +183,11 @@ pub use skill::{ 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_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 e70e59d7b..1536eaf61 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 a00f9990b..8c59435bc 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. @@ -142,6 +146,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", @@ -223,6 +228,26 @@ 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, + /// Print a skill's full body plus its absolute directory. + Show, + /// Read one of a skill's supplementary files. + Cat, + #[command(external_subcommand)] + Unknown(Vec), +} + #[derive(Subcommand, Debug, Clone)] pub(crate) enum DiagnoseCommand { /// Print the agent-readable diagnose CLI capability contract. diff --git a/crates/aionui-app/src/commands/cmd_capabilities.rs b/crates/aionui-app/src/commands/cmd_capabilities.rs index bda96017f..d14178483 100644 --- a/crates/aionui-app/src/commands/cmd_capabilities.rs +++ b/crates/aionui-app/src/commands/cmd_capabilities.rs @@ -105,6 +105,21 @@ fn data() -> Value { "runtime_token_required_for_context_and_call": true, "does_not_accept_identity_authority_from_stdin": true } + }, + { + "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": [ @@ -139,3 +154,74 @@ fn print_envelope(data: Value) -> Result<(), ()> { .and_then(|_| stdout.write_all(b"\n")) .map_err(|_| ()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn domain_names() -> Vec { + data()["domains"] + .as_array() + .expect("domains must be an array") + .iter() + .map(|domain| domain["name"].as_str().expect("every domain has a name").to_owned()) + .collect() + } + + /// The `skills` domain must be discoverable from the top-level index. + /// + /// This is guarding against a defect that already happened once: `session` + /// shipped a complete `session capabilities` contract but was never listed + /// here, so an agent running `aioncore capabilities` had no way to learn the + /// domain existed. Channel A depends on exactly that discovery step — an + /// unlisted `skills` domain would silently push every agent onto the + /// `[LOAD_SKILL]` fallback. + /// + /// NB: `session` is deliberately not asserted here. Registering it is a + /// separate in-flight change touching this same array; if that change is + /// dropped, the gap it fixes is still open and this comment is the pointer. + #[test] + fn the_domain_index_lists_the_skills_domain() { + assert!( + domain_names().iter().any(|name| name == "skills"), + "skills is missing from the domain index: {:?}", + domain_names() + ); + } + + /// Every listed domain must tell the agent how to fetch its own contract. + /// A domain in the index with no `contract_command` is discoverable but + /// unusable, which is the same dead end from the other direction. + #[test] + fn every_listed_domain_advertises_a_contract_command() { + for domain in data()["domains"].as_array().unwrap() { + let name = domain["name"].as_str().unwrap(); + let contract = domain["contract_command"].as_str().unwrap_or_default(); + assert!( + !contract.is_empty(), + "domain {name:?} is listed without a contract_command" + ); + let invocation = domain["invocation"].as_str().unwrap_or_default(); + assert!( + invocation.contains(contract), + "domain {name:?} invocation {invocation:?} must invoke its own contract_command {contract:?}" + ); + } + } + + /// A read-only domain must not advertise write authority: the agent reads + /// `safety.can_write` to decide whether a command is safe to try. + #[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 09feb1bac..ff936a26d 100644 --- a/crates/aionui-app/src/commands/mod.rs +++ b/crates/aionui-app/src/commands/mod.rs @@ -11,12 +11,14 @@ pub(crate) mod cmd_doctor; pub(crate) mod cmd_prepare_managed_resources; 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 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; @@ -27,6 +29,7 @@ pub(crate) use cmd_doctor::run_doctor; pub(crate) use cmd_prepare_managed_resources::run_prepare_managed_resources; 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 error::{CliBoundaryCode, CliBoundaryError}; 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 96e884cdb..843bd52a3 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 4eae3ff50..531cf14cb 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}; @@ -367,6 +368,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)); @@ -399,6 +403,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 881abfb21..dfdd3f069 100644 --- a/crates/aionui-app/src/router/state.rs +++ b/crates/aionui-app/src/router/state.rs @@ -41,6 +41,7 @@ use aionui_session_message::state::SessionMessageRouterState; use aionui_session_message::targets::MentionableTargets; use aionui_shell::ShellRouterState; use aionui_sidebar::{SidebarRouterState, SidebarService}; +use aionui_skill_runtime::{SkillRuntimeRouterState, SkillRuntimeService}; use aionui_system::{ ClientPrefService, ConnectionTestRouterState, ConnectionTestService, FeedbackDiagnosticsService, ModelFetchService, ProtocolDetectionService, ProviderService, RuntimePrepareService, SettingsService, SystemRouterState, @@ -142,6 +143,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, @@ -326,6 +328,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)), @@ -360,6 +363,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-conversation/src/skill_resolver.rs b/crates/aionui-conversation/src/skill_resolver.rs index 160e46dc4..6061c8d1d 100644 --- a/crates/aionui-conversation/src/skill_resolver.rs +++ b/crates/aionui-conversation/src/skill_resolver.rs @@ -115,20 +115,10 @@ 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() - } -} +// 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; #[async_trait] impl SkillResolver for ExtensionSkillResolver { diff --git a/crates/aionui-extension/src/skill_service.rs b/crates/aionui-extension/src/skill_service.rs index 57a71ebb9..95aa7de43 100644 --- a/crates/aionui-extension/src/skill_service.rs +++ b/crates/aionui-extension/src/skill_service.rs @@ -1460,6 +1460,30 @@ async fn path_is_dir(path: &Path) -> bool { .unwrap_or(false) } +/// Strip a `SKILL.md`'s YAML frontmatter, returning just the body. +/// +/// 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. +/// +/// 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() + } +} + /// Resolve a skill name to its on-disk source directory using the same /// search order as [`materialize_skills_for_agent`]. Returns `None` if /// no matching directory exists in any known source. @@ -2126,7 +2150,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)] 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 +} From a554212f1c514233752e4c24db5676d11edc6af0 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 25 Aug 2026 10:27:30 +0800 Subject: [PATCH 08/13] feat(skills): inject a truncated dual-channel index, and give agy and aionrs an injection path at all Two parts. The second is a gap the design missed. Injection content. Descriptions are truncated to 200 CHARS (not bytes -- a byte slice splits a multi-byte codepoint), which is safe only because channel A exists: an agent seeing a cut description can fetch the full text with 'skills show'. The block now advertises both channels, command first, since that is a normal tool call rather than text-matching plus an extra turn. The index is also sorted, because discovery returns from a HashMap and an unordered block would vary between opens of the same conversation, churning context for nothing. The light/heavy switch now reads skill_delivery instead of native_skills_dirs.is_some(). The old signal conflated 'declares a workspace skills directory' with 'can discover skills natively', so a vendor with a declared directory but no working discovery got light injection and therefore no skills. Both layer-1 modes are light; previously Protocol would have fallen to the heavy branch and given codex a duplicate index. agy and aionrs had NO injection path. Index injection has only ever lived in the ACP prompt pipeline, and neither backend runs it -- verified by grepping every caller. Their skills arrived solely through the workspace links this refactor removes, so without this they would lose skills outright. Worse for agy: its backend consumed only init.mcp_servers, so the assistant's preset context was being dropped too. agy now prepends the composed block to its first -p invocation only (it resumes with --conversation afterwards and would otherwise repeat the block every turn) and finally honours extra_args, which it ignored entirely. aionrs folds the index into system_prompt, its one always-present channel. Both compose through the same function as the ACP hook, so the three paths cannot drift into different wording. Two existing tests pinned the literal '[LOAD_SKILL: skill-name]', which the second channel legitimately rewrote to ''. Rewritten to assert the protocol MARKER plus the new command channel -- the meaningful part of the original check -- rather than deleted. --- Cargo.lock | 23 +++ .../src/capability/first_message_injector.rs | 195 +++++++++++++++--- .../skill_manager/prompt_builder.rs | 156 +++++++++++++- crates/aionui-ai-agent/src/factory/aionrs.rs | 82 +++++++- .../src/factory/antigravity.rs | 12 +- crates/aionui-ai-agent/src/factory/mod.rs | 39 +++- .../aionui-ai-agent/src/manager/acp/hooks.rs | 15 +- crates/aionui-ai-agent/src/session_agent.rs | 9 +- .../tests/skill_manager_integration.rs | 10 +- .../src/backend/antigravity/argv.rs | 77 ++++++- .../src/backend/antigravity/conn.rs | 7 + 11 files changed, 580 insertions(+), 45 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4e90fa318..6fbd13207 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", @@ -956,6 +957,28 @@ dependencies = [ "url", ] +[[package]] +name = "aionui-skill-runtime" +version = "0.1.71" +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.71" 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/prompt_builder.rs b/crates/aionui-ai-agent/src/capability/skill_manager/prompt_builder.rs index 35d05e87a..422c44213 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,72 @@ 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()); + lines.push( + "To get a skill's full content, prefer running \ + `\"$AIONUI_HELPER_BIN\" skills show ` — it also returns the skill's absolute \ + directory, and `\"$AIONUI_HELPER_BIN\" skills cat /` reads its \ + supplementary files." + .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 +194,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 +376,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/aionrs.rs b/crates/aionui-ai-agent/src/factory/aionrs.rs index 2f648b3ea..3b739c1df 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,18 @@ 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. + 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 +2284,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 92e9954a6..5e4b4475e 100644 --- a/crates/aionui-ai-agent/src/factory/antigravity.rs +++ b/crates/aionui-ai-agent/src/factory/antigravity.rs @@ -123,7 +123,7 @@ pub(super) async fn build( let mut runtime_env = ctx.runtime_env.clone(); runtime_env.extend(hook_env); - let delivery = crate::factory::resolve_skill_delivery( + let mut delivery = crate::factory::resolve_skill_delivery( deps.as_ref(), &ctx.user_id, &ctx.conversation_id, @@ -131,6 +131,16 @@ pub(super) async fn build( &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 { diff --git a/crates/aionui-ai-agent/src/factory/mod.rs b/crates/aionui-ai-agent/src/factory/mod.rs index b5a2939fc..03ad8fb04 100644 --- a/crates/aionui-ai-agent/src/factory/mod.rs +++ b/crates/aionui-ai-agent/src/factory/mod.rs @@ -86,6 +86,14 @@ pub struct ResolvedSkillDelivery { /// `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. @@ -140,7 +148,36 @@ pub(crate) async fn resolve_skill_delivery( "skill_delivery: resolved delivery plan for session" ); - ResolvedSkillDelivery { plan, skill_dirs } + 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 { 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/session_agent.rs b/crates/aionui-ai-agent/src/session_agent.rs index 63f07aa3e..824cb879a 100644 --- a/crates/aionui-ai-agent/src/session_agent.rs +++ b/crates/aionui-ai-agent/src/session_agent.rs @@ -1734,7 +1734,14 @@ 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. 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-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..0c8066d1e 100644 --- a/crates/aionui-session/src/backend/antigravity/conn.rs +++ b/crates/aionui-session/src/backend/antigravity/conn.rs @@ -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() { From d72025d89eed0eb0bb3709da391f42ffa78c0f94 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 25 Aug 2026 11:37:06 +0800 Subject: [PATCH 09/13] feat(skills)!: stop delivering skills into the workspace The destructive half of the refactor, landing only now that every replacement channel is in place -- so no vendor passes through a state with no skills. AionUi no longer symlinks skills into {workspace}/.claude/skills and friends. That path applied to user-selected workspaces too (frequently git repositories), created directories for the 5 auto-inject defaults even with zero user configuration, stacked one set per member per vendor in a shared team workspace, was re-created on the next build if deleted by hand, and degraded into COPYING real files in when a symlink failed. All of it is gone, including the fallback. Four G1 regression tests snapshot the workspace file tree before and after and assert it is unchanged: a user-selected workspace holding the user's OWN .claude/skills, an auto-provisioned one (the removed code took it as licence to write, so testing only the selected case would miss half the defect), five vendors sharing one directory, and a second build. The snapshot walks with symlink_metadata so it never descends THROUGH a link. One acknowledged exception, whitelisted by name: .agents/hooks.json, agy's PreToolUse permission gate. Removing it would silently downgrade agy's security. Any other new workspace write fails these tests. agy's slash-command list now comes from the session's resolved skill dirs instead of scanning {workspace}/.agents/skills, which no longer exists. Strictly better: it lists exactly this conversation's enabled skills, so a stale residue cannot reappear in the picker. write_mcp_config is deleted. Its own header documented a measurement proving agy does NOT read {workspace}/.agents/mcp_config.json -- the same MCP server is invoked from the global path and not from the workspace file, where agy logs 'empty component: prompt section mcp_servers' -- and descriptor.rs already declares agy with no MCP transport. It was writing a file with no consumer, into the user's workspace. Three existing tests encoded the removed behaviour. Rewritten, not deleted, per the test-failure policy: the same scenarios now assert the workspace is untouched and the view is correct. The link_workspace_skills unit tests went with the function; among them a Windows case for the copy fallback, a degradation this change deliberately cancels -- a failed link is now skipped with a warn rather than materialising real files into a directory we treat as disposable. A startup sweep reaps view directories whose conversation is gone, keyed by the (user, conversation) PAIR so one user's deletion cannot reap another's. Listing failure reaps nothing, which is the safe direction: a leaked view costs disk, a wrongly-deleted one costs a session its skills. Also measured, and it changed the config: agy gets NO directory allow-listing. 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 would add one argument per skill for no effect and would read as a guarantee the measurement contradicts. AgentType::native_skills_dirs() is retired from delivery but kept as reference data, matching the column's own fate -- with a comment against reintroducing it as a signal, since conflating 'declares a directory' with 'can discover skills' is how a vendor could end up with neither. --- .../tests/agent_types_integration.rs | 6 +- crates/aionui-api-types/src/custom_agent.rs | 24 ++ crates/aionui-app/src/services.rs | 27 ++ .../tests/message_service_integration.rs | 9 - crates/aionui-common/src/enums.rs | 16 +- crates/aionui-conversation/src/service.rs | 193 +-------- .../aionui-conversation/src/service_test.rs | 248 ++++++++--- .../aionui-conversation/src/skill_resolver.rs | 45 +- .../aionui-conversation/src/stream_relay.rs | 9 - .../aionui-conversation/tests/active_lease.rs | 9 - .../tests/conversation_crud.rs | 9 - .../tests/conversation_extended.rs | 9 - .../tests/stream_relay_tool_call.rs | 9 - .../tests/thinking_persistence.rs | 9 - crates/aionui-cron/src/executor.rs | 18 - .../aionui-cron/tests/service_integration.rs | 18 - .../migrations/042_agent_skill_delivery.sql | 19 +- .../aionui-db/src/repository/conversation.rs | 15 + .../src/repository/sqlite_conversation.rs | 39 ++ .../tests/agent_skill_delivery_migration.rs | 12 +- crates/aionui-extension/src/lib.rs | 10 +- crates/aionui-extension/src/skill_service.rs | 401 ------------------ crates/aionui-extension/src/skill_view.rs | 66 +++ .../tests/common/mod.rs | 10 - .../src/backend/antigravity/conn.rs | 34 +- .../src/backend/antigravity/mcp_config.rs | 219 ---------- .../src/backend/antigravity/mod.rs | 1 - .../src/backend/antigravity/skills.rs | 97 +++-- 28 files changed, 506 insertions(+), 1075 deletions(-) delete mode 100644 crates/aionui-session/src/backend/antigravity/mcp_config.rs 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-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-app/src/services.rs b/crates/aionui-app/src/services.rs index 4b762a0f4..be7bdc129 100644 --- a/crates/aionui-app/src/services.rs +++ b/crates/aionui-app/src/services.rs @@ -252,6 +252,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-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 c094645a7..2338f3c10 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/service.rs b/crates/aionui-conversation/src/service.rs index 07b7dfe1b..7c0cb86c4 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,51 +1285,18 @@ 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 - { - 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" - ); - } - } - // Build the per-conversation skill VIEW under AionUi's own data dir. // - // Unconditional on the delivery mode, unlike the workspace wiring above: - // 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. + // 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 @@ -4019,7 +3986,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(); @@ -4141,7 +4108,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()) @@ -4661,7 +4628,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 { @@ -4868,75 +4835,6 @@ 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); - - // Re-sync the skill VIEW first, and unconditionally: build-task runs on - // every session open, so this is where a view deleted out-of-band (or - // never built, on a conversation created before this feature) comes back. - // Deliberately outside the auto-workspace guard below -- that guard is - // about writing into the workspace, which the view does not do. - self.ensure_session_skill_view(context).await; - - 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; - } - } - - 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) - .await; - 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) - .await; - debug!( - conversation_id = %row.id, - workspace = %workspace.display(), - links = n, - "ensured skill symlinks in auto workspace" - ); - } - /// Rebuild this conversation's skill view directory from its snapshot. /// /// Idempotent and independent of the workspace: the view lives under @@ -5174,19 +5072,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(); @@ -5366,18 +5251,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(), @@ -5390,36 +5263,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 5379b3d67..aced1ba42 100644 --- a/crates/aionui-conversation/src/service_test.rs +++ b/crates/aionui-conversation/src/service_test.rs @@ -58,12 +58,6 @@ use crate::{ConversationAgentTurnRequest, ConversationAgentTurnStatus, Conversat mod acp_error_recovery_test; #[derive(Clone, Debug)] -struct SkillLinkCall { - workspace: PathBuf, - rel_dirs: Vec, - skill_names: Vec, -} - struct RecordedViewSync { user_id: String, conversation_id: String, @@ -72,7 +66,6 @@ struct RecordedViewSync { struct RecordingSkillResolver { names: Vec, - links: Arc>>, views: Arc>>, view_removals: Arc>>, } @@ -124,7 +117,6 @@ 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())), } @@ -147,28 +139,6 @@ 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(), - skill_names: skills.iter().map(|skill| skill.name.clone()).collect(), - }); - - 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 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(), @@ -8609,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"); @@ -8627,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!({ @@ -8649,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"); @@ -8684,21 +8666,173 @@ async fn warmup_restores_skill_links_for_custom_workspace() { })) .unwrap(); let resp = svc.create("user-1", req).await.unwrap(); - - std::fs::remove_dir_all(workspace.join(".claude")).unwrap(); - assert!(!workspace.join(".claude/skills/cron").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(".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].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 diff --git a/crates/aionui-conversation/src/skill_resolver.rs b/crates/aionui-conversation/src/skill_resolver.rs index 6061c8d1d..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; @@ -52,12 +55,6 @@ 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. @@ -115,11 +112,6 @@ async fn load_resolved_skill_bodies(skills: &[ResolvedAgentSkill]) -> Vec Vec { @@ -163,7 +155,7 @@ impl SkillResolver for ExtensionSkillResolver { &self.paths, self.skill_repo.as_ref(), user_id, - "workspace-link", + "skill-resolve", names, ) .await @@ -179,23 +171,6 @@ 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 { - Ok(n) => n, - Err(e) => { - tracing::warn!( - workspace = %workspace.display(), - error = %e, - "link_workspace_skills failed" - ); - 0 - } - } - } - 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, @@ -243,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); diff --git a/crates/aionui-conversation/src/stream_relay.rs b/crates/aionui-conversation/src/stream_relay.rs index d56a82bcc..3cb389e9a 100644 --- a/crates/aionui-conversation/src/stream_relay.rs +++ b/crates/aionui-conversation/src/stream_relay.rs @@ -1134,15 +1134,6 @@ mod tests { }) .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/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 4b8fa72df..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())); diff --git a/crates/aionui-db/migrations/042_agent_skill_delivery.sql b/crates/aionui-db/migrations/042_agent_skill_delivery.sql index 95c4025e8..0ac6dbd21 100644 --- a/crates/aionui-db/migrations/042_agent_skill_delivery.sql +++ b/crates/aionui-db/migrations/042_agent_skill_delivery.sql @@ -88,13 +88,20 @@ WHERE backend = 'codex'; -- install surface, not a session one; `opencode debug config` shows no external -- skill root among its resolved top-level keys). -- --- agy DOES take `--add-dir` and its own help marks it "(repeatable)", so the --- allow-listing shape applies. Note agy's argv already passes --- `--dangerously-skip-permissions` unconditionally (`antigravity/argv.rs`), so --- this may well be a no-op there; the parameter is idempotent and harmless --- either way, and the live check is tracked as an acceptance item. +-- 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":["--add-dir","{skill_dir}"]}', + skill_delivery = '{"mode":"injected","allow_dir_args":[]}', updated_at = CAST(strftime('%s','now') AS INTEGER) * 1000 WHERE backend = 'antigravity'; diff --git a/crates/aionui-db/src/repository/conversation.rs b/crates/aionui-db/src/repository/conversation.rs index db6a6abe6..16c946ff2 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_conversation.rs b/crates/aionui-db/src/repository/sqlite_conversation.rs index 6339d4c75..ee10ffdc9 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 = ?") @@ -2210,6 +2217,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_skill_delivery_migration.rs b/crates/aionui-db/tests/agent_skill_delivery_migration.rs index 1eec5b97e..edb8c5650 100644 --- a/crates/aionui-db/tests/agent_skill_delivery_migration.rs +++ b/crates/aionui-db/tests/agent_skill_delivery_migration.rs @@ -76,16 +76,18 @@ async fn codex_gets_protocol_delivery() { 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_allow_dir_args() { +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!(["--add-dir", "{skill_dir}"]) - ); + assert_eq!(delivery["allow_dir_args"], serde_json::json!([])); } #[tokio::test] diff --git a/crates/aionui-extension/src/lib.rs b/crates/aionui-extension/src/lib.rs index 9b1f0ffad..c6ef4f277 100644 --- a/crates/aionui-extension/src/lib.rs +++ b/crates/aionui-extension/src/lib.rs @@ -56,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 95aa7de43..48d6ca361 100644 --- a/crates/aionui-extension/src/skill_service.rs +++ b/crates/aionui-extension/src/skill_service.rs @@ -1368,98 +1368,6 @@ 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`. -/// -/// 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}`. -/// -/// 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" - ); - } - } - } - } - 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) -} - /// Strip a `SKILL.md`'s YAML frontmatter, returning just the body. /// /// Lives here rather than in a consumer because BOTH skill-delivery channels @@ -2209,118 +2117,6 @@ 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)] pub(crate) async fn create_symlink(src: &Path, dst: &Path) -> Result<(), ExtensionError> { @@ -3378,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); @@ -3694,187 +3476,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 index 777693e94..37f0460f7 100644 --- a/crates/aionui-extension/src/skill_view.rs +++ b/crates/aionui-extension/src/skill_view.rs @@ -98,6 +98,14 @@ pub async fn rebuild_view( let view = view_dir(data_dir, user_id, conversation_id)?; let skills_dir = view.join(SKILLS_SUBDIR); + // 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), @@ -140,6 +148,23 @@ pub async fn rebuild_view( } } + if !previously_linked.is_empty() { + let added = skills + .iter() + .filter(|skill| !previously_linked.contains(&skill.name)) + .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, @@ -150,6 +175,22 @@ pub async fn rebuild_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)?; @@ -342,6 +383,31 @@ mod tests { 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(); 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/conn.rs b/crates/aionui-session/src/backend/antigravity/conn.rs index 0c8066d1e..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 { 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()); } } From 2e8a7f7863d229ab76cf6e8e6476f6461f59e67c Mon Sep 17 00:00:00 2001 From: zynx <> Date: Tue, 25 Aug 2026 11:41:06 +0800 Subject: [PATCH 10/13] docs(skills): record the codex supplementary-file measurement in the migration A full app-server turn probe (initialize -> extraRoots/set -> thread/start -> turn/start) had the agent read a symlinked skill's references/ file from 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 -- which is why its config carries none. Recorded with its caveat: the probe ran with approvalPolicy: never, so under the on-request default an out-of-cwd read may surface an approval request rather than being refused. That is the same behaviour claude has and AionUi already renders a permission card for it. --- .../migrations/042_agent_skill_delivery.sql | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/crates/aionui-db/migrations/042_agent_skill_delivery.sql b/crates/aionui-db/migrations/042_agent_skill_delivery.sql index 0ac6dbd21..e05d24004 100644 --- a/crates/aionui-db/migrations/042_agent_skill_delivery.sql +++ b/crates/aionui-db/migrations/042_agent_skill_delivery.sql @@ -77,6 +77,18 @@ WHERE backend = 'codebuddy'; -- 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 From 925b30fd19e08bd8f31c753cbb1e9550fe1c1758 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Wed, 26 Aug 2026 14:45:31 +0800 Subject: [PATCH 11/13] fix(skills): serialize the session skill view rebuild per conversation `rebuild_view` had no synchronization, and a first turn calls it three times -- conversation create, runtime ensure, and send -- with the last two landing under a millisecond apart. Because the rebuild is "snapshot the link names, wipe the tree, relink", concurrent calls interleave destructively. Observed against a real backend, four distinct symptoms of the one race: * a spurious "skills added to a conversation that already had a view" warning, computed against a snapshot taken mid-wipe; * four `File exists (os error 17)` 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; * `sync_skill_view failed ... Directory not empty (os error 66)` at ERROR level, when one call's `remove_dir_all` hit a directory the other was repopulating -- failing the whole rebuild; * that failure left a conversation's view holding 3 of its 4 enabled skills, so its agent could not see the fourth for over two hours, until an unrelated later rebuild happened to restore the link. The benign outcomes were timing luck, not a property of the code. Two changes: * serialize per (user_id, conversation_id), so unrelated conversations still rebuild concurrently. Unheld entries are dropped on each acquisition so the registry cannot grow with every conversation the process has opened, and ids are validated before a lock entry can be registered. * return early when the view already matches the snapshot. This is the common case -- two of a turn's three rebuilds ask for the set the first just linked -- and skipping them also stops briefly emptying a directory a CLI may be reading. The count now reports what the view holds rather than what one call personally created, and the log separates "requested" from "linkable" so a missing source directory can no longer read as a short link count. Sequential tests cannot reach this: the destructive interleaving needs one call's wipe to land inside another's relink loop. The new test drives eight concurrent rebuilds of the same conversation and asserts on the result rather than on timing -- every call reports the full set, and the final view holds exactly the requested skills. Removing the lock makes it fail (1 of 6 linked). --- crates/aionui-extension/src/skill_view.rs | 186 ++++++++++++++++++++-- 1 file changed, 170 insertions(+), 16 deletions(-) diff --git a/crates/aionui-extension/src/skill_view.rs b/crates/aionui-extension/src/skill_view.rs index 37f0460f7..4ba464a72 100644 --- a/crates/aionui-extension/src/skill_view.rs +++ b/crates/aionui-extension/src/skill_view.rs @@ -26,10 +26,12 @@ //! 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::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; +use std::sync::{Arc, LazyLock, Mutex as BlockingMutex}; -use tracing::{info, warn}; +use tokio::sync::Mutex; +use tracing::{debug, info, warn}; use crate::error::ExtensionError; use crate::skill_service::{ResolvedAgentSkill, create_symlink}; @@ -87,17 +89,72 @@ fn plugin_manifest_body() -> String { .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 -/// created. Idempotent: build-task calls this on every session open. +/// 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, @@ -113,6 +170,21 @@ pub async fn rebuild_view( ) .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. @@ -127,15 +199,7 @@ pub async fn rebuild_view( tokio::fs::create_dir_all(&skills_dir).await?; let mut created = 0usize; - for skill in skills { - if !tokio::fs::try_exists(&skill.source_path).await.unwrap_or(false) { - warn!( - skill = %skill.name, - conversation_id = %conversation_id, - "skill_view: source directory missing; skipping this skill" - ); - continue; - } + for skill in &linkable { let target = skills_dir.join(&skill.name); match create_symlink(&skill.source_path, &target).await { Ok(()) => created += 1, @@ -149,10 +213,7 @@ pub async fn rebuild_view( } if !previously_linked.is_empty() { - let added = skills - .iter() - .filter(|skill| !previously_linked.contains(&skill.name)) - .count(); + let added = desired.difference(&previously_linked).count(); if added > 0 { warn!( user_id = %user_id, @@ -169,6 +230,7 @@ pub async fn rebuild_view( user_id = %user_id, conversation_id = %conversation_id, skills = created, + linkable = linkable.len(), requested = skills.len(), "skill_view: rebuilt session skill view" ); @@ -500,4 +562,96 @@ mod tests { 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" + ); + } } From 6fef33e66c662215f0809cb536db4f90e56a8636 Mon Sep 17 00:00:00 2001 From: zynx <> Date: Wed, 26 Aug 2026 14:45:49 +0800 Subject: [PATCH 12/13] fix(skills): teach channel A the invocation the CLI actually accepts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The injected skills index told agents to run `skills show ` and `skills cat /`, but both subcommands are declared as argument-less variants that read a JSON object on stdin, so every taught form was rejected outright: $ aioncore skills show mermaid error: unexpected argument 'mermaid' found Reaching for `--help` did not help either: it printed `Usage: aioncore skills show` and said nothing about stdin, so the obvious self-service path was a dead end too. Measured on live agents, three of them across two backends: aionrs burned three failed tool calls and tripped its tool-recovery guard; opencode burned five, recovering only after it happened to try `skills capabilities`. `skills list` masked the problem because it takes no arguments. This is not model capability. The same agents got `session send-message` right on the first attempt every time, because that command's contract is documented correctly in its skill body. Wrong docs cost three to five rounds; correct docs cost none. Three changes, one per gap found: * the index now teaches the stdin form and points at `skills capabilities` as the authoritative contract; * `Show` and `Cat` document the stdin payload and a runnable example, so `--help` is a working path rather than a dead end. The rationale stays in a plain comment -- help text an agent reads should be purely instructional; * a test extracts every `skills …` command line the index teaches and asserts clap accepts it, and asserts `skills show ` stays a parse error. Nothing structural stopped the two crates from disagreeing before: the text side only asserted the string mentioned `$AIONUI_HELPER_BIN`, which the broken wording satisfied. Restoring the old wording makes the new test fail. --- .../skill_manager/prompt_builder.rs | 17 ++++- crates/aionui-app/src/cli.rs | 73 +++++++++++++++++++ 2 files changed, 87 insertions(+), 3 deletions(-) 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 422c44213..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 @@ -55,11 +55,22 @@ pub fn build_skills_index_text(skills: &[SkillIndex]) -> String { )); } 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 \ - `\"$AIONUI_HELPER_BIN\" skills show ` — it also returns the skill's absolute \ - directory, and `\"$AIONUI_HELPER_BIN\" skills cat /` reads its \ - supplementary files." + `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( diff --git a/crates/aionui-app/src/cli.rs b/crates/aionui-app/src/cli.rs index e7689b5dd..ea39878b0 100644 --- a/crates/aionui-app/src/cli.rs +++ b/crates/aionui-app/src/cli.rs @@ -256,9 +256,27 @@ pub(crate) enum SkillsCommand { 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), @@ -1289,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" + ); + } } From 3aa9bba2bd362a91d62a79b769c1871a25241c9e Mon Sep 17 00:00:00 2001 From: zynx <> Date: Thu, 27 Aug 2026 11:06:31 +0800 Subject: [PATCH 13/13] docs(skills): record why aionrs stays off the shared delivery decision The ACP and Antigravity lanes resolve skill delivery through `resolve_skill_delivery` and emit a "resolved delivery plan for session" anchor log; aionrs does neither, and a maintainer grepping for that log while debugging an aionrs session would find the gap and reasonably suspect a bug. It is deliberate. aionrs is an in-process agent compiled into aioncore, with no launch-argument channel (argv) and no wire protocol (protocol), so `system_prompt` injection is the only delivery it can physically perform. Its `skill_delivery` column has no applicable value other than its `injected` default, so routing it through the shared decision would fetch a metadata row and compute a plan this lane cannot use, to honor modes it cannot execute. Comment only -- no behavior change. Records the decision where the next reader will look, and marks the spot aionrs would re-join the shared path if it ever gains a real native skill channel. --- crates/aionui-ai-agent/src/factory/aionrs.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/aionui-ai-agent/src/factory/aionrs.rs b/crates/aionui-ai-agent/src/factory/aionrs.rs index 3b739c1df..d4c09cde6 100644 --- a/crates/aionui-ai-agent/src/factory/aionrs.rs +++ b/crates/aionui-ai-agent/src/factory/aionrs.rs @@ -73,6 +73,19 @@ pub(super) async fn build( // 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,