diff --git a/apps/.i18n/native-source.json b/apps/.i18n/native-source.json index 8adbf255d512..bca88e4b3331 100644 --- a/apps/.i18n/native-source.json +++ b/apps/.i18n/native-source.json @@ -1301,17 +1301,17 @@ "kind": "conditional-branch", "line": 6384, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", - "source": "ClawHub did not return an installable version for ${skill.slug}.", + "source": "ClawHub did not return an installable version for ${skill.reference}.", "surface": "android", - "id": "native.android.57c31cd4bd7c5146" + "id": "native.android.e5b0558c26183ff4" }, { "kind": "ui-call", "line": 6400, "path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", - "source": "Could not load ClawHub details for ${skill.slug}.", + "source": "Could not load ClawHub details for ${skill.reference}.", "surface": "android", - "id": "native.android.ecf82b1ac9d18307" + "id": "native.android.5d4e8b19fbd1204f" }, { "kind": "ui-call", @@ -1827,7 +1827,7 @@ }, { "kind": "conditional-branch", - "line": 120, + "line": 129, "path": "apps/android/app/src/main/java/ai/openclaw/app/SkillManagement.kt", "source": "The Gateway evaluated a different ClawHub release. Review the skill again before installing.", "surface": "android", @@ -1835,7 +1835,7 @@ }, { "kind": "conditional-branch", - "line": 232, + "line": 241, "path": "apps/android/app/src/main/java/ai/openclaw/app/SkillManagement.kt", "source": "The result for $slug is unknown. Reconnect, refresh Skills, then retry; the Gateway safely joins a matching install that is still running.", "surface": "android", @@ -10979,7 +10979,7 @@ }, { "kind": "ui-call", - "line": 606, + "line": 608, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Version $it", "surface": "android", @@ -10987,7 +10987,7 @@ }, { "kind": "ui-call", - "line": 615, + "line": 621, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Installing", "surface": "android", @@ -10995,7 +10995,7 @@ }, { "kind": "ui-call", - "line": 616, + "line": 622, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Loading", "surface": "android", @@ -11003,7 +11003,7 @@ }, { "kind": "ui-call", - "line": 676, + "line": 682, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Needs attention", "surface": "android", @@ -11011,7 +11011,7 @@ }, { "kind": "ui-call", - "line": 714, + "line": 720, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Review", "surface": "android", @@ -11019,7 +11019,7 @@ }, { "kind": "ui-call", - "line": 720, + "line": 726, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Dismiss", "surface": "android", @@ -11027,7 +11027,7 @@ }, { "kind": "ui-call", - "line": 727, + "line": 733, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Acknowledge Gateway warning and install", "surface": "android", @@ -11035,7 +11035,7 @@ }, { "kind": "ui-call", - "line": 746, + "line": 752, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Review ClawHub skill", "surface": "android", @@ -11043,7 +11043,7 @@ }, { "kind": "ui-call", - "line": 753, + "line": 759, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Version", "surface": "android", @@ -11051,7 +11051,7 @@ }, { "kind": "ui-call", - "line": 754, + "line": 760, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Publisher", "surface": "android", @@ -11059,7 +11059,7 @@ }, { "kind": "ui-call", - "line": 756, + "line": 762, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "The Gateway will verify this exact release with ClawHub before download. If the release needs explicit risk acknowledgement, Android will show the Gateway warning before retrying.", "surface": "android", @@ -11067,7 +11067,7 @@ }, { "kind": "ui-call", - "line": 764, + "line": 770, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Verify and install", "surface": "android", @@ -11075,7 +11075,7 @@ }, { "kind": "ui-call", - "line": 769, + "line": 775, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Cancel", "surface": "android", @@ -11083,7 +11083,7 @@ }, { "kind": "ui-call", - "line": 811, + "line": 817, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "All", "surface": "android", @@ -11091,7 +11091,7 @@ }, { "kind": "ui-call", - "line": 813, + "line": 819, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Needs Setup", "surface": "android", @@ -11099,7 +11099,7 @@ }, { "kind": "ui-call", - "line": 830, + "line": 836, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Off", "surface": "android", @@ -11107,7 +11107,7 @@ }, { "kind": "ui-call", - "line": 831, + "line": 837, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Setup", "surface": "android", @@ -11115,7 +11115,7 @@ }, { "kind": "ui-call", - "line": 832, + "line": 838, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Ready", "surface": "android", @@ -11123,7 +11123,7 @@ }, { "kind": "ui-call", - "line": 845, + "line": 851, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Disabled", "surface": "android", @@ -11131,7 +11131,7 @@ }, { "kind": "ui-call", - "line": 846, + "line": 852, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Blocked", "surface": "android", @@ -11139,7 +11139,7 @@ }, { "kind": "ui-call", - "line": 847, + "line": 853, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Not available to this agent", "surface": "android", @@ -11147,7 +11147,7 @@ }, { "kind": "ui-call", - "line": 849, + "line": 855, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Needs setup", "surface": "android", @@ -11155,7 +11155,7 @@ }, { "kind": "ui-call", - "line": 857, + "line": 863, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin.", "surface": "android", @@ -11163,7 +11163,7 @@ }, { "kind": "ui-call", - "line": 858, + "line": 864, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI.", "surface": "android", @@ -11171,7 +11171,7 @@ }, { "kind": "ui-call", - "line": 859, + "line": 865, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI.", "surface": "android", @@ -11179,7 +11179,7 @@ }, { "kind": "ui-call", - "line": 861, + "line": 867, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "This skill is installed but not currently eligible to run. Use desktop or CLI for configuration changes.", "surface": "android", @@ -11187,7 +11187,7 @@ }, { "kind": "ui-call", - "line": 862, + "line": 868, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI.", "surface": "android", @@ -11195,7 +11195,7 @@ }, { "kind": "ui-call", - "line": 867, + "line": 873, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "No missing items", "surface": "android", @@ -11203,7 +11203,7 @@ }, { "kind": "ui-call", - "line": 868, + "line": 874, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "1 missing item", "surface": "android", @@ -11211,7 +11211,7 @@ }, { "kind": "ui-call", - "line": 869, + "line": 875, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "$count missing items", "surface": "android", @@ -11219,7 +11219,7 @@ }, { "kind": "ui-call", - "line": 874, + "line": 880, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "This skill needs 1 setup item. Android shows what is installed; setup/config changes stay on desktop or CLI.", "surface": "android", @@ -11227,7 +11227,7 @@ }, { "kind": "ui-call", - "line": 875, + "line": 881, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "This skill needs $count setup items. Android shows what is installed; setup/config changes stay on desktop or CLI.", "surface": "android", @@ -11235,7 +11235,7 @@ }, { "kind": "ui-call", - "line": 880, + "line": 886, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Built-in", "surface": "android", @@ -11243,7 +11243,7 @@ }, { "kind": "ui-call", - "line": 880, + "line": 886, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Bundled", "surface": "android", @@ -11251,7 +11251,7 @@ }, { "kind": "ui-call", - "line": 881, + "line": 887, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Installed", "surface": "android", @@ -11259,7 +11259,7 @@ }, { "kind": "ui-call", - "line": 882, + "line": 888, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Workspace", "surface": "android", @@ -11267,7 +11267,7 @@ }, { "kind": "ui-call", - "line": 883, + "line": 889, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Extra", "surface": "android", @@ -11275,7 +11275,7 @@ }, { "kind": "ui-call", - "line": 884, + "line": 890, "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "source": "Skill", "surface": "android", @@ -18659,7 +18659,7 @@ }, { "kind": "ui-localized-call", - "line": 186, + "line": 188, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Install %@", "surface": "apple", @@ -18667,7 +18667,7 @@ }, { "kind": "ui-named-argument", - "line": 194, + "line": 196, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Installed Skills", "surface": "apple", @@ -18675,7 +18675,7 @@ }, { "kind": "conditional-branch", - "line": 200, + "line": 202, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "No skills found", "surface": "apple", @@ -18683,7 +18683,7 @@ }, { "kind": "conditional-branch", - "line": 200, + "line": 202, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Skills unavailable", "surface": "apple", @@ -18691,7 +18691,7 @@ }, { "kind": "conditional-branch", - "line": 202, + "line": 204, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Try a different search or refresh from the gateway.", "surface": "apple", @@ -18699,7 +18699,7 @@ }, { "kind": "conditional-branch", - "line": 203, + "line": 205, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Connect a gateway to load workspace skills.", "surface": "apple", @@ -18707,7 +18707,7 @@ }, { "kind": "ui-localized-call", - "line": 315, + "line": 317, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Setup: %@", "surface": "apple", @@ -18715,7 +18715,7 @@ }, { "kind": "ui-localized-call", - "line": 337, + "line": 339, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Set up %@", "surface": "apple", @@ -18723,7 +18723,7 @@ }, { "kind": "ui-localized-call", - "line": 349, + "line": 351, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Edit %@", "surface": "apple", @@ -18731,7 +18731,7 @@ }, { "kind": "ui-localized-call", - "line": 353, + "line": 355, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "saving", "surface": "apple", @@ -18739,7 +18739,7 @@ }, { "kind": "ui-call", - "line": 416, + "line": 418, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Skill unavailable", "surface": "apple", @@ -18747,7 +18747,7 @@ }, { "kind": "ui-call", - "line": 418, + "line": 420, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Return to the skills list and choose another skill.", "surface": "apple", @@ -18755,7 +18755,7 @@ }, { "kind": "ui-modifier", - "line": 425, + "line": 427, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Skill", "surface": "apple", @@ -18763,7 +18763,7 @@ }, { "kind": "ui-call", - "line": 460, + "line": 462, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Close", "surface": "apple", @@ -18771,7 +18771,7 @@ }, { "kind": "ui-localized-call", - "line": 480, + "line": 482, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Workspace skill", "surface": "apple", @@ -18779,7 +18779,7 @@ }, { "kind": "ui-call", - "line": 495, + "line": 497, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Enabled globally", "surface": "apple", @@ -18787,7 +18787,7 @@ }, { "kind": "ui-call", - "line": 505, + "line": 507, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "API key", "surface": "apple", @@ -18795,7 +18795,7 @@ }, { "kind": "ui-call", - "line": 513, + "line": 515, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Save key", "surface": "apple", @@ -18803,7 +18803,7 @@ }, { "kind": "ui-call", - "line": 520, + "line": 522, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Get key", "surface": "apple", @@ -18811,7 +18811,7 @@ }, { "kind": "conditional-branch", - "line": 574, + "line": 576, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Off", "surface": "apple", @@ -18819,7 +18819,7 @@ }, { "kind": "conditional-branch", - "line": 574, + "line": 576, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "On", "surface": "apple", @@ -18827,7 +18827,7 @@ }, { "kind": "ui-call", - "line": 580, + "line": 582, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Setup", "surface": "apple", @@ -18835,7 +18835,7 @@ }, { "kind": "ui-localized-call", - "line": 584, + "line": 586, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Missing: %@", "surface": "apple", @@ -18843,7 +18843,7 @@ }, { "kind": "ui-call", - "line": 589, + "line": 591, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "No missing requirements reported.", "surface": "apple", @@ -18851,7 +18851,7 @@ }, { "kind": "ui-named-argument", - "line": 612, + "line": 614, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Key", "surface": "apple", @@ -18859,7 +18859,7 @@ }, { "kind": "ui-named-argument", - "line": 613, + "line": 615, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Source", "surface": "apple", @@ -18867,7 +18867,7 @@ }, { "kind": "conditional-branch", - "line": 696, + "line": 698, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Skill policy reset.", "surface": "apple", @@ -18875,7 +18875,7 @@ }, { "kind": "conditional-branch", - "line": 696, + "line": 698, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Skill policy saved.", "surface": "apple", @@ -18883,7 +18883,7 @@ }, { "kind": "conditional-branch", - "line": 709, + "line": 711, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Skill disabled.", "surface": "apple", @@ -18891,7 +18891,7 @@ }, { "kind": "conditional-branch", - "line": 709, + "line": 711, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "Skill enabled.", "surface": "apple", @@ -18899,7 +18899,7 @@ }, { "kind": "conditional-branch", - "line": 721, + "line": 723, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "API key cleared.", "surface": "apple", @@ -18907,7 +18907,7 @@ }, { "kind": "conditional-branch", - "line": 722, + "line": 724, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "API key saved.", "surface": "apple", @@ -18915,7 +18915,7 @@ }, { "kind": "ui-localized-call", - "line": 877, + "line": 879, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "off", "surface": "apple", @@ -18923,7 +18923,7 @@ }, { "kind": "ui-localized-call", - "line": 879, + "line": 881, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "blocked", "surface": "apple", @@ -18931,7 +18931,7 @@ }, { "kind": "ui-localized-call", - "line": 881, + "line": 883, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "disabled", "surface": "apple", @@ -18939,7 +18939,7 @@ }, { "kind": "ui-localized-call", - "line": 883, + "line": 885, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "setup", "surface": "apple", @@ -18947,7 +18947,7 @@ }, { "kind": "ui-localized-call", - "line": 885, + "line": 887, "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "source": "enabled", "surface": "apple", @@ -30763,7 +30763,7 @@ }, { "kind": "conditional-branch", - "line": 138, + "line": 145, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "Installed", "surface": "apple", @@ -30771,7 +30771,7 @@ }, { "kind": "conditional-branch", - "line": 138, + "line": 145, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "Review", "surface": "apple", @@ -30779,7 +30779,7 @@ }, { "kind": "ui-call", - "line": 153, + "line": 160, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "Review ClawHub skill", "surface": "apple", @@ -30787,7 +30787,7 @@ }, { "kind": "ui-call", - "line": 155, + "line": 162, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "The Gateway will verify this exact release with ClawHub before download.", "surface": "apple", @@ -30795,7 +30795,7 @@ }, { "kind": "ui-call", - "line": 161, + "line": 168, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "Verify and install", "surface": "apple", @@ -30803,7 +30803,7 @@ }, { "kind": "ui-call", - "line": 182, + "line": 189, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "Gateway warning", "surface": "apple", @@ -30811,7 +30811,7 @@ }, { "kind": "ui-call", - "line": 187, + "line": 194, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "Review warning details", "surface": "apple", @@ -30819,7 +30819,7 @@ }, { "kind": "ui-call", - "line": 193, + "line": 200, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "Expand and review the Gateway warning before acknowledging this exact version.", "surface": "apple", @@ -30827,7 +30827,7 @@ }, { "kind": "ui-call", - "line": 198, + "line": 205, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "Cancel", "surface": "apple", @@ -30835,7 +30835,7 @@ }, { "kind": "ui-call", - "line": 199, + "line": 206, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "Acknowledge and install", "surface": "apple", @@ -30843,7 +30843,7 @@ }, { "kind": "ui-call", - "line": 218, + "line": 225, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "Version", "surface": "apple", @@ -30851,7 +30851,7 @@ }, { "kind": "ui-call", - "line": 219, + "line": 226, "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "source": "Publisher", "surface": "apple", @@ -43075,7 +43075,7 @@ }, { "kind": "conditional-branch", - "line": 348, + "line": 355, "path": "apps/shared/OpenClawKit/Sources/OpenClawKit/SkillManagement.swift", "source": "The Gateway evaluated a different ClawHub release. Review the skill again before installing.", "surface": "apple", diff --git a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt index 16c0cfb1ddc5..5ee5a496aa8c 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt @@ -6362,7 +6362,7 @@ class NodeRuntime private constructor( publishGatewayData(gatewayScope) { _clawHubSkillSearchState.value = _clawHubSkillSearchState.value.copy( - reviewingSlug = skill.slug, + reviewingSlug = skill.reference, installReview = null, acknowledgeSlug = null, acknowledgeVersion = null, @@ -6371,7 +6371,7 @@ class NodeRuntime private constructor( ) } try { - val response = requestGatewayData(gatewayScope, "skills.detail", clawHubDetailParams(skill.slug)) + val response = requestGatewayData(gatewayScope, "skills.detail", clawHubDetailParams(skill.reference)) val review = parseClawHubInstallReview(response, skill, json) publishGatewayData(gatewayScope) { if (clawHubSkillReviewSeq.get() == reviewSeq) { @@ -6381,7 +6381,7 @@ class NodeRuntime private constructor( installReview = review, errorText = if (review == null) { - "ClawHub did not return an installable version for ${skill.slug}." + "ClawHub did not return an installable version for ${skill.reference}." } else { null }, @@ -6397,7 +6397,7 @@ class NodeRuntime private constructor( _clawHubSkillSearchState.value.copy( reviewingSlug = null, errorText = - nativeString("Could not load ClawHub details for \${skill.slug}.", skill.slug), + nativeString("Could not load ClawHub details for \${skill.reference}.", skill.reference), ) } } diff --git a/apps/android/app/src/main/java/ai/openclaw/app/SkillManagement.kt b/apps/android/app/src/main/java/ai/openclaw/app/SkillManagement.kt index 0caff2c1efe6..ab2d94252147 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/SkillManagement.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/SkillManagement.kt @@ -28,10 +28,18 @@ data class GatewayClawHubSkillSearchState( data class GatewayClawHubSkillSummary( val slug: String, + val installRef: String?, val displayName: String, val summary: String?, val version: String?, -) +) { + /** + * Several publishers can share one slug, so the Gateway-supplied reference is what identifies a + * result, what distinguishes rows, and what detail and install must send back. + */ + val reference: String + get() = installRef?.trim()?.takeIf(String::isNotEmpty) ?: slug +} data class GatewayClawHubInstallReview( val slug: String, @@ -60,6 +68,7 @@ internal fun parseClawHubSearchResults( val displayName = value.string("displayName") ?: return@mapNotNull null GatewayClawHubSkillSummary( slug = slug, + installRef = value.string("installRef"), displayName = displayName, summary = value.string("summary"), version = value.string("version"), diff --git a/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt b/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt index 88b6344fd4af..2c7e5b931588 100644 --- a/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt +++ b/apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt @@ -599,15 +599,21 @@ private fun ClawHubSkillSearchPanel( if (state.results.isNotEmpty()) { ClawListPanel(items = state.results) { skill -> val installed = - skill.version?.let { version -> isClawHubSkillInstalled(installedSkills, skill.slug, version) } - ?: isClawHubSkillInstalled(installedSkills, skill.slug) + skill.version?.let { version -> isClawHubSkillInstalled(installedSkills, skill.reference, version) } + ?: isClawHubSkillInstalled(installedSkills, skill.reference) + val subtitleParts = + listOfNotNull( + skill.summary, + skill.reference, + skill.version?.let { nativeString("Version \$it", it) }, + ) ClawDetailRow( title = skill.displayName, - subtitle = listOfNotNull(skill.summary, skill.version?.let { nativeString("Version \$it", it) }).joinToString(" · "), + subtitle = subtitleParts.joinToString(" · "), leading = { ClawTextBadge(text = skillBadge(skill.displayName)) }, trailing = { - val reviewing = state.reviewingSlug == skill.slug - val installing = isClawHubSkillOperationActive(state.installingSlugs, skill.slug) + val reviewing = state.reviewingSlug == skill.reference + val installing = isClawHubSkillOperationActive(state.installingSlugs, skill.reference) ClawSecondaryButton( text = when { diff --git a/apps/android/app/src/test/java/ai/openclaw/app/SkillManagementTest.kt b/apps/android/app/src/test/java/ai/openclaw/app/SkillManagementTest.kt index 443d4b67d5f0..02dfca819d4f 100644 --- a/apps/android/app/src/test/java/ai/openclaw/app/SkillManagementTest.kt +++ b/apps/android/app/src/test/java/ai/openclaw/app/SkillManagementTest.kt @@ -20,7 +20,7 @@ class SkillManagementTest { fun searchResultsKeepOnlyIdentifiedSkills() { val results = parseClawHubSearchResults( - """{"results":[{"slug":" alpha ","displayName":"Alpha","summary":"Useful","version":"1.2.3"},{"slug":"missing-name"},{"displayName":"Missing slug"}]}""", + """{"results":[{"slug":" alpha ","installRef":"@alice/alpha","displayName":"Alpha","summary":"Useful","version":"1.2.3"},{"slug":"missing-name"},{"displayName":"Missing slug"}]}""", json, ) @@ -28,6 +28,7 @@ class SkillManagementTest { listOf( GatewayClawHubSkillSummary( slug = "alpha", + installRef = "@alice/alpha", displayName = "Alpha", summary = "Useful", version = "1.2.3", @@ -37,12 +38,23 @@ class SkillManagementTest { ) } + @Test + fun sameSlugResultsKeepSeparatePublisherReferences() { + val results = + parseClawHubSearchResults( + """{"results":[{"slug":"email","installRef":"@alice/email","displayName":"Email"},{"slug":"email","installRef":"@bob/email","displayName":"Email"},{"slug":"orphan","displayName":"Orphan"}]}""", + json, + ) + + assertEquals(listOf("@alice/email", "@bob/email", "orphan"), results.map { it.reference }) + } + @Test fun detailBindsExactVersionAndPublisherIdentity() { val review = parseClawHubInstallReview( """{"skill":{"displayName":"Alpha Skill","summary":"Reviewed metadata"},"latestVersion":{"version":"2.0.0"},"owner":{"displayName":"Alice","handle":"alice"}}""", - GatewayClawHubSkillSummary("alpha", "Alpha", null, null), + GatewayClawHubSkillSummary("alpha", null, "Alpha", null, null), json, ) @@ -63,7 +75,7 @@ class SkillManagementTest { val review = parseClawHubInstallReview( """{"skill":{"displayName":"Alpha"},"latestVersion":{"version":"2.0.0"},"owner":{"handle":"alice"}}""", - GatewayClawHubSkillSummary("alpha", "Alpha", null, "1.9.0"), + GatewayClawHubSkillSummary("alpha", null, "Alpha", null, "1.9.0"), json, ) @@ -75,7 +87,7 @@ class SkillManagementTest { val review = parseClawHubInstallReview( """{"skill":{"displayName":"Alpha"},"owner":{"handle":"alice"}}""", - GatewayClawHubSkillSummary("alpha", "Alpha", null, null), + GatewayClawHubSkillSummary("alpha", null, "Alpha", null, null), json, ) diff --git a/apps/ios/Sources/Design/AgentProModels.swift b/apps/ios/Sources/Design/AgentProModels.swift index 994f76758f4c..b24ef3759a26 100644 --- a/apps/ios/Sources/Design/AgentProModels.swift +++ b/apps/ios/Sources/Design/AgentProModels.swift @@ -263,9 +263,15 @@ struct ClawHubSearchResponseLite: Decodable { struct ClawHubSearchResultLite: Decodable { let slug: String + let installRef: String? let displayName: String let summary: String? let version: String? + + /// Several publishers can share one slug, so install must send the Gateway-supplied reference. + var reference: String { + self.installRef ?? self.slug + } } struct ClawHubInstallParams: Encodable { diff --git a/apps/ios/Sources/Design/AgentProTab+Skills.swift b/apps/ios/Sources/Design/AgentProTab+Skills.swift index a6076ff3e468..aef9672cce7f 100644 --- a/apps/ios/Sources/Design/AgentProTab+Skills.swift +++ b/apps/ios/Sources/Design/AgentProTab+Skills.swift @@ -146,7 +146,7 @@ extension AgentProTab { if !self.clawHubResults.isEmpty { VStack(spacing: 0) { let results = Array(self.clawHubResults.prefix(8)) - ForEach(Array(results.enumerated()), id: \.element.slug) { index, result in + ForEach(Array(results.enumerated()), id: \.element.reference) { index, result in self.clawHubResultRow(result) if index < results.count - 1 { Divider().padding(.leading, 42) @@ -160,14 +160,16 @@ extension AgentProTab { } func clawHubResultRow(_ result: ClawHubSearchResultLite) -> some View { - let installing = clawHubInstallSlug == result.slug + let installing = clawHubInstallSlug == result.reference return HStack(alignment: .top, spacing: 10) { ProIconBadge(systemName: "sparkles", color: OpenClawBrand.accent) VStack(alignment: .leading, spacing: 3) { Text(result.displayName) .font(OpenClawType.subheadSemiBold) .lineLimit(1) - Text(result.summary ?? result.slug) + // This surface installs directly, so the publisher reference always shows: + // same-slug rows are otherwise identical and the button would look ambiguous. + Text(result.summary.map { "\($0) · \(result.reference)" } ?? result.reference) .font(OpenClawType.caption) .foregroundStyle(.secondary) .lineLimit(2) @@ -741,11 +743,11 @@ extension AgentProTab { @MainActor func installClawHubSkill(_ result: ClawHubSearchResultLite) async { guard liveGatewayConnected else { return } - clawHubInstallSlug = result.slug + clawHubInstallSlug = result.reference clawHubErrorText = nil defer { self.clawHubInstallSlug = nil } do { - let params = ClawHubInstallParams(slug: result.slug) + let params = ClawHubInstallParams(slug: result.reference) _ = try await self.requestGateway(method: "skills.install", params: params, timeoutSeconds: 125) await appModel.refreshGatewayOverviewIfConnected() await refreshOverview(force: true) diff --git a/apps/ios/Sources/Design/SettingsSkillsDestination.swift b/apps/ios/Sources/Design/SettingsSkillsDestination.swift index 843ec14cd03f..5ad61cfaa90d 100644 --- a/apps/ios/Sources/Design/SettingsSkillsDestination.swift +++ b/apps/ios/Sources/Design/SettingsSkillsDestination.swift @@ -387,10 +387,10 @@ struct SettingsSkillsDestination: View { ClawHubSkillRow( skill: skill, installed: skill.version.map { - SkillManagementContract.installed(self.installedSkills, slug: skill.slug, version: $0) - } ?? SkillManagementContract.installed(self.installedSkills, slug: skill.slug), - isBusy: self.reviewingSlug == skill.slug || self.installingSlug.map { - SkillManagementContract.sameClawHubSkill($0, skill.slug) + SkillManagementContract.installed(self.installedSkills, slug: skill.reference, version: $0) + } ?? SkillManagementContract.installed(self.installedSkills, slug: skill.reference), + isBusy: self.reviewingSlug == skill.reference || self.installingSlug.map { + SkillManagementContract.sameClawHubSkill($0, skill.reference) } == true, onReview: { Task { await self.review(skill) } }) } @@ -523,7 +523,7 @@ struct SettingsSkillsDestination: View { let gatewayID = self.appModel.connectedGatewayID let operationID = UUID() self.reviewID = operationID - self.reviewingSlug = skill.slug + self.reviewingSlug = skill.reference self.notice = nil defer { if self.reviewID == operationID { @@ -535,7 +535,7 @@ struct SettingsSkillsDestination: View { let route = try await gatewayRoute() let data = try await request( method: "skills.detail", - params: ClawHubDetailRequest(slug: skill.slug), + params: ClawHubDetailRequest(slug: skill.reference), timeoutSeconds: 20, route: route) let detail = try JSONDecoder().decode(ClawHubSkillDetail.self, from: data) @@ -907,12 +907,12 @@ private struct ClawHubSkillRow: View { ProIconBadge(systemName: "shippingbox", color: self.installed ? OpenClawBrand.ok : OpenClawBrand.accent) VStack(alignment: .leading, spacing: 4) { Text(self.skill.displayName).font(OpenClawType.subheadSemiBold) - Text(self.skill.summary ?? self.skill.slug) + Text(self.skill.summary ?? self.skill.reference) .font(OpenClawType.caption) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) HStack(spacing: 6) { - Text(self.skill.slug).font(OpenClawType.monoSmall).foregroundStyle(.secondary) + Text(self.skill.reference).font(OpenClawType.monoSmall).foregroundStyle(.secondary) if let version = self.skill.version { Text(verbatim: version).font(OpenClawType.monoSmall).foregroundStyle(.secondary) } diff --git a/apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift b/apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift index 2813079f5cec..c52aea4ae789 100644 --- a/apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift +++ b/apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift @@ -66,13 +66,13 @@ struct ClawHubSkillsBrowser: View { installed: skill.version.map { SkillManagementContract.installed( self.installedSkills, - slug: skill.slug, + slug: skill.reference, version: $0) } ?? SkillManagementContract.installed( self.installedSkills, - slug: skill.slug), - isBusy: self.model.reviewingSlug == skill.slug || self.model.installingSlug.map { - SkillManagementContract.sameClawHubSkill($0, skill.slug) + slug: skill.reference), + isBusy: self.model.reviewingSlug == skill.reference || self.model.installingSlug.map { + SkillManagementContract.sameClawHubSkill($0, skill.reference) } == true, showsDivider: index != self.model.results.count - 1) { @@ -124,10 +124,17 @@ private struct ClawHubSkillResultRow: View { let showsDivider: Bool let onReview: () -> Void + /// Same-slug rows share a display name and often a summary, so the reference always shows: + /// it is the only thing that tells them apart and what review and install send back. + private var subtitle: String { + guard let summary = self.skill.summary else { return self.skill.reference } + return "\(summary) · \(self.skill.reference)" + } + var body: some View { SettingsCardRow( title: .verbatim(self.skill.displayName), - subtitle: .verbatim(self.skill.summary ?? self.skill.slug), + subtitle: .verbatim(self.subtitle), showsDivider: self.showsDivider) { if let version = self.skill.version { @@ -288,14 +295,14 @@ private final class ClawHubSkillsBrowserModel { func review(_ skill: ClawHubSkillSummary) async { guard self.reviewingSlug == nil else { return } - self.reviewingSlug = skill.slug + self.reviewingSlug = skill.reference self.notice = nil defer { self.reviewingSlug = nil } do { guard let route = await GatewayConnection.shared.captureRoute() else { throw ClawHubSkillsBrowserError.gatewayUnavailable } - let detail = try await GatewayConnection.shared.skillsDetail(slug: skill.slug, on: route) + let detail = try await GatewayConnection.shared.skillsDetail(slug: skill.reference, on: route) guard let review = ClawHubSkillInstallReview(detail: detail, fallback: skill) else { throw ClawHubSkillsBrowserError.missingInstallVersion } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawKit/SkillManagement.swift b/apps/shared/OpenClawKit/Sources/OpenClawKit/SkillManagement.swift index d265cb5b0dfc..fc2d21e59ba5 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawKit/SkillManagement.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawKit/SkillManagement.swift @@ -220,12 +220,19 @@ public struct ClawHubInstalledSkillLink: Codable, Sendable { public struct ClawHubSkillSummary: Codable, Identifiable, Hashable, Sendable { public let slug: String + public let installRef: String? public let displayName: String public let summary: String? public let version: String? + /// Several publishers can share one slug, so the Gateway-supplied reference is what identifies + /// a result, what distinguishes rows, and what detail and install must send back. + public var reference: String { + self.installRef?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty ?? self.slug + } + public var id: String { - self.slug + self.reference } } diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/SkillManagementTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/SkillManagementTests.swift index 3f6890106419..143f4f1ca80e 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/SkillManagementTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/SkillManagementTests.swift @@ -22,6 +22,16 @@ struct SkillManagementTests { #expect(review.author == "Molly") } + @Test func `same-slug results keep separate publisher references`() throws { + let data = Data( + #"{"results":[{"slug":"email","installRef":"@alice/email","displayName":"Email"},{"slug":"email","installRef":"@bob/email","displayName":"Email"},{"slug":"orphan","displayName":"Orphan"}]}"# + .utf8) + let search = try JSONDecoder().decode(ClawHubSkillSearchResult.self, from: data) + + #expect(search.results.map(\.reference) == ["@alice/email", "@bob/email", "orphan"]) + #expect(search.results.map(\.id) == ["@alice/email", "@bob/email", "orphan"]) + } + @Test func `risk acknowledgement stays bound to reviewed version`() { let matching = GatewayResponseError( method: "skills.install", diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json index 5b2b8281b8ef..17022a10d3de 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness-runtime.json @@ -1 +1 @@ -{"contentHash":"a5cf59f64e5a88c3affe8a177f9bb4bd38e80c364506509b7fc6427a211bd371","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} +{"contentHash":"1fed941553a74f9184dddc98beca612513d50f2b29c6325a03ca75679f331fbf","entrypoint":"agent-harness-runtime","importSpecifier":"openclaw/plugin-sdk/agent-harness-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json index 64bada020b8a..c9306ce6b94f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/agent-harness.json +++ b/docs/.generated/plugin-sdk-api-baseline/agent-harness.json @@ -1 +1 @@ -{"contentHash":"4f73f681d60fd6eed1abe5e4d542c456798e5221fa2c97dcd480d1f33a4b1ecc","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} +{"contentHash":"6b6c13f1d3006b6fb7c08bddd514d32ff35b9eb3df87d5add52bc5a48eb958ec","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-core.json b/docs/.generated/plugin-sdk-api-baseline/channel-core.json index fcff48a5ddaf..5b78b8d68d18 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-core.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-core.json @@ -1 +1 @@ -{"contentHash":"a5062fea6e891209423fcfeae5a88c92695f672d80181b646951879e0613e911","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} +{"contentHash":"fe51d5aeedac8e00896266f2939fd45faed0743a0a9c18215f0a3ec42c58fa2e","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json index f447bdae090f..d826daabe896 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-entry-contract.json @@ -1 +1 @@ -{"contentHash":"1897ffe4a97dfe51f41f506e443ab06529463c9366ee1bed542ee807684e32aa","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} +{"contentHash":"f63861dfcfe98655424f048df56a607a7e7fc72fdc10cf4fb55891072605f46e","entrypoint":"channel-entry-contract","importSpecifier":"openclaw/plugin-sdk/channel-entry-contract"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-message.json b/docs/.generated/plugin-sdk-api-baseline/channel-message.json index fef4a91f3436..351adaed717b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-message.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-message.json @@ -1 +1 @@ -{"contentHash":"74285633ffd2690a31f53c3b612b74aef87e887935e11e84e9898e48f4db5d75","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} +{"contentHash":"9cdddd8b710598fd4d042eb5c3ad35d50efe873aba6021754d0d68f07cdb5f37","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json index 13408964bde3..3aa8c3408516 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-outbound.json @@ -1 +1 @@ -{"contentHash":"331508088936dad14f0caef18bf8d26d51e6db76febaa4045567b97212cff647","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} +{"contentHash":"feda02ab30be6ddc116227f65614720d6da9ded7cfb393164c9bdf6d397b0554","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} diff --git a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json index a98ed1aa5fea..8fc81dd2e291 100644 --- a/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json +++ b/docs/.generated/plugin-sdk-api-baseline/channel-plugin-common.json @@ -1 +1 @@ -{"contentHash":"8a776a6fa7cca763c8c80b882a10cea91c7d3f0e7fdb637217c7698f27f7b749","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} +{"contentHash":"86ab71232b1182d0a7d61ee8784eb1ce7fca725e265c93111f4cfffdfbb09fc4","entrypoint":"channel-plugin-common","importSpecifier":"openclaw/plugin-sdk/channel-plugin-common"} diff --git a/docs/.generated/plugin-sdk-api-baseline/core.json b/docs/.generated/plugin-sdk-api-baseline/core.json index f5bb04d7a770..1bf6816fd537 100644 --- a/docs/.generated/plugin-sdk-api-baseline/core.json +++ b/docs/.generated/plugin-sdk-api-baseline/core.json @@ -1 +1 @@ -{"contentHash":"90014e82f746dd9cdfad10b81132431e34da5eb7b06f489e70affc4e3dac5921","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} +{"contentHash":"a67fc659b7a00b0ba664f0c3ed032507ca280169aef2d5168b7e484fa6e7359a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} diff --git a/docs/.generated/plugin-sdk-api-baseline/discord.json b/docs/.generated/plugin-sdk-api-baseline/discord.json index 8e40487020f4..c1c9eca86f5d 100644 --- a/docs/.generated/plugin-sdk-api-baseline/discord.json +++ b/docs/.generated/plugin-sdk-api-baseline/discord.json @@ -1 +1 @@ -{"contentHash":"e40b030c61bbdcf0c7c15620a47ff88a2d0e9aac8d363c019459cece7dfff47c","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} +{"contentHash":"e0b7667e42591bf8df977daa460aeb108b123af63159646b8a681b1eb51cce58","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} diff --git a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json index d2a7b6f9043d..fcb471a164ce 100644 --- a/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/gateway-runtime.json @@ -1 +1 @@ -{"contentHash":"084e6289f1636eb48c6f6ef99c3f51ac862708bbb9a837f9d50edcece6987080","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} +{"contentHash":"a3eed0ce9e6224b14b8eff90bd4333078efc5c6509b70a144e6a243a554acb61","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json index b6f87cfde78c..7f8aa28bd9c7 100644 --- a/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json +++ b/docs/.generated/plugin-sdk-api-baseline/inbound-reply-dispatch.json @@ -1 +1 @@ -{"contentHash":"a4f9073153fbd595667d0e9f7375f17046e7f8d0fe88d317359420125e4ce563","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} +{"contentHash":"c2297d83ad3593f864e8e0010bcf2e7dfd9dc4787d9fc43c06c076ca2094e6c7","entrypoint":"inbound-reply-dispatch","importSpecifier":"openclaw/plugin-sdk/inbound-reply-dispatch"} diff --git a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json index 509cba0803f8..51f69558776b 100644 --- a/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/meeting-runtime.json @@ -1 +1 @@ -{"contentHash":"f643813855b0359515d0faf0c58b9ef323399e5ba7ffced29ef81305051d66aa","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} +{"contentHash":"9acb37896771b9fa94638e07a7c59cbcb7c28f98f4f9c6cea50212ff25b13ffa","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json index 17173f171ab8..e3e3729e1566 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-entry.json @@ -1 +1 @@ -{"contentHash":"0f9097423b1b563db164cab7cc7e1f9d1eaed13eb12ce16523a72d8b1614375b","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} +{"contentHash":"6fb970713ffc85efd0478c1a8ad2ce71946cf9426c9f28c0025b196f03d87cd7","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} diff --git a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json index 38dc8c334889..71ffe58fcf5f 100644 --- a/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/plugin-runtime.json @@ -1 +1 @@ -{"contentHash":"cdb8cb034a2e2f4cb2e511b706c964ddc2704761d17feceb2999fa41e87feea7","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} +{"contentHash":"03bce874a07e9d0197227386465d4f7fe8d125209df56d73b4ebac1b9d814a8c","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json index 8042c0bbb7dd..18306e8d3f33 100644 --- a/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json +++ b/docs/.generated/plugin-sdk-api-baseline/provider-catalog-runtime.json @@ -1 +1 @@ -{"contentHash":"6d565d7d935dd58b836d23335b7e75a2985e34740b42e06d78f02e743f2ae374","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} +{"contentHash":"51732b1ebb5b12b0a16c27454713b30a155c3a89d216301909f543acd6e06f3b","entrypoint":"provider-catalog-runtime","importSpecifier":"openclaw/plugin-sdk/provider-catalog-runtime"} diff --git a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json index 36d860502618..52b61132db06 100644 --- a/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json +++ b/docs/.generated/plugin-sdk-api-baseline/tool-plugin.json @@ -1 +1 @@ -{"contentHash":"3f9b816df61c93c1e3e78245451f69878bc53d194c6737f6d4770728387a9061","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} +{"contentHash":"fe1c2291373e523397e96b4d2c92e55f5ce138937a00d6fa49b2e9a9af5490f0","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} diff --git a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json index dafa2f5b1046..cd7eef432b80 100644 --- a/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json +++ b/docs/.generated/plugin-sdk-api-baseline/webhook-ingress.json @@ -1 +1 @@ -{"contentHash":"c7d16f543fc05054a0dc3b9b3206b9ba383f18244631ce83d4bf7c46ee26dab3","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} +{"contentHash":"3211b4cfd054e6df3673507cc886616fa06180c3b7951e4693bd54f389a233e4","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 108eb27cf082..99d3a86af5ff 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -356,6 +356,14 @@ export const SkillsUploadCommitParamsSchema = closedObject({ sha256: Type.Optional(Sha256String), }); +/** + * ClawHub resolves a bare slug against every publisher, so requests that carry only the slug + * fail with 409 AMBIGUOUS_SKILL_SLUG once two publishers share it. Clients send the reference + * `skills.search` returned for the entry the operator picked. + */ +const CLAWHUB_SKILL_REF_DESCRIPTION = + "ClawHub skill reference: `@owner/slug`, `skills-sh:owner/repo/slug`, or a bare `slug` when no publisher is known."; + /** Installs a skill from legacy install id, ClawHub, or uploaded archive. */ export const SkillsInstallParamsSchema = Type.Union([ closedObject({ @@ -374,7 +382,7 @@ export const SkillsInstallParamsSchema = Type.Union([ closedObject({ agentId: Type.Optional(NonEmptyString), source: Type.Literal("clawhub"), - slug: NonEmptyString, + slug: Type.String({ minLength: 1, description: CLAWHUB_SKILL_REF_DESCRIPTION }), version: Type.Optional(NonEmptyString), force: Type.Optional(Type.Boolean()), acknowledgeClawHubRisk: Type.Optional(Type.Boolean()), @@ -420,17 +428,25 @@ export const SkillsSearchResultSchema = closedObject({ closedObject({ score: Type.Number(), slug: NonEmptyString, + installRef: Type.Optional( + Type.String({ + minLength: 1, + description: + "Publisher-qualified reference for this result. Send it as `slug` to skills.detail and skills.install; several publishers can share one slug.", + }), + ), displayName: NonEmptyString, summary: Type.Optional(Type.String()), + icon: Type.Optional(Type.Union([Type.String(), Type.Null()])), version: Type.Optional(NonEmptyString), updatedAt: Type.Optional(Type.Integer()), }), ), }); -/** Reads registry detail for one skill slug. */ +/** Reads registry detail for one skill. */ export const SkillsDetailParamsSchema = closedObject({ - slug: NonEmptyString, + slug: Type.String({ minLength: 1, description: CLAWHUB_SKILL_REF_DESCRIPTION }), }); /** Reads current security verdicts for configured skills. */ diff --git a/src/cli/skills-cli.commands.test.ts b/src/cli/skills-cli.commands.test.ts index a763e5507680..a70848207067 100644 --- a/src/cli/skills-cli.commands.test.ts +++ b/src/cli/skills-cli.commands.test.ts @@ -401,6 +401,7 @@ describe("skills cli commands", () => { { slug: "calendar", ownerHandle: "demo-owner", + installRef: "@demo-owner/calendar", displayName: "Calendar", summary: "CalDAV helpers", version: "1.2.3", @@ -408,6 +409,7 @@ describe("skills cli commands", () => { { slug: "calendar", ownerHandle: "work-owner", + installRef: "@work-owner/calendar", displayName: "Team Calendar", }, ]); @@ -464,6 +466,7 @@ describe("skills cli commands", () => { { slug: "oauth-helper", ownerHandle: "demo-owner", + installRef: "@demo-owner/oauth-helper", displayName: "Oauth\nHelper", summary: "Automate OAuth login flows.\nSupports multiple providers.\n\nFeatures:\n- Confirm before authorizing", diff --git a/src/cli/skills-cli.ts b/src/cli/skills-cli.ts index 9d17b13d88b4..561bfa2281eb 100644 --- a/src/cli/skills-cli.ts +++ b/src/cli/skills-cli.ts @@ -595,22 +595,14 @@ export function registerSkillsCli(program: Command) { } for (const entry of results) { const installRef = normalizeOptionalString(entry.installRef); - const ownerHandle = normalizeOptionalString(entry.ownerHandle); - const slug = formatClawHubSearchText(entry.slug); - const skillsShInstallRef = - installRef?.startsWith("skills-sh:") && - entry.trustState === CLAWHUB_SKILLS_SH_TRUST_STATE - ? installRef - : undefined; - const skillRef = skillsShInstallRef - ? formatClawHubSearchText(skillsShInstallRef) - : ownerHandle - ? `@${formatClawHubSearchText(ownerHandle)}/${slug}` - : slug; + const skillRef = formatClawHubSearchText(installRef ?? entry.slug); + const isExternalSource = + installRef?.startsWith("skills-sh:") === true && + entry.trustState === CLAWHUB_SKILLS_SH_TRUST_STATE; const version = entry.version ? ` v${formatClawHubSearchText(entry.version)}` : ""; const summary = entry.summary ? ` ${formatClawHubSearchText(entry.summary)}` : ""; const displayName = formatClawHubSearchText(entry.displayName); - const trust = skillsShInstallRef ? ` ${CLAWHUB_SKILLS_SH_TRUST_LABEL}` : ""; + const trust = isExternalSource ? ` ${CLAWHUB_SKILLS_SH_TRUST_LABEL}` : ""; defaultRuntime.log(`${skillRef}${version} ${displayName}${summary}${trust}`); } } catch (err) { diff --git a/src/gateway/server-methods/skills.publisher-identity.test.ts b/src/gateway/server-methods/skills.publisher-identity.test.ts new file mode 100644 index 000000000000..45a1f3371b10 --- /dev/null +++ b/src/gateway/server-methods/skills.publisher-identity.test.ts @@ -0,0 +1,163 @@ +// Boundary proof for issue #117633: two publishers share one ClawHub slug, and the reference a +// client picks from skills.search must reach the outbound ClawHub request unchanged. Only the +// HTTP layer is faked here; search, the Gateway handlers, and the detail client are real. + +import { expectDefined } from "@openclaw/normalization-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const installSkillFromClawHubMock = vi.fn(); + +vi.mock("../../config/config.js", () => ({ + getRuntimeConfig: vi.fn(() => ({})), + writeConfigFile: vi.fn(), +})); + +vi.mock("../../agents/agent-scope.js", () => ({ + listAgentIds: vi.fn(() => ["main"]), + resolveDefaultAgentId: vi.fn(() => "main"), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), +})); + +vi.mock("../../skills/lifecycle/install.js", () => ({ + installSkill: vi.fn(), +})); + +vi.mock("../../skills/lifecycle/clawhub.js", async (importOriginal) => ({ + ...(await importOriginal()), + installSkillFromClawHub: (...args: unknown[]) => installSkillFromClawHubMock(...args), +})); + +const { skillsHandlers } = await import("./skills.js"); +const { callGatewayHandler } = await import("./skills.test-helpers.js"); + +const SLUG = "imap-smtp-email"; +const PUBLISHERS = ["gzlicanyi", "wangchenyu8"] as const; + +function searchPayload() { + return { + results: [ + ...PUBLISHERS.map((ownerHandle, index) => ({ + score: 6120 - index, + slug: SLUG, + ownerHandle, + displayName: SLUG, + summary: `Email skill by ${ownerHandle}`, + version: "1.0.0", + })), + // An external source that names its own reference instead of a registry publisher. + { + score: 6100, + slug: SLUG, + installRef: `skills-sh:acme/tools/${SLUG}`, + displayName: SLUG, + summary: "Email skill from skills.sh", + version: "1.0.0", + }, + ], + }; +} + +let requestedUrls: string[] = []; + +function fakeClawHub(input: string): Response { + const url = new URL(input); + requestedUrls.push(input); + if (url.pathname === "/api/v1/search") { + return Response.json(searchPayload()); + } + if (url.pathname === `/api/v1/skills/${SLUG}`) { + const ownerHandle = url.searchParams.get("ownerHandle"); + if (!ownerHandle) { + // Real ClawHub refuses to guess a publisher instead of returning an arbitrary match. + return Response.json( + { code: "AMBIGUOUS_SKILL_SLUG", message: `Found multiple skills with the slug "${SLUG}"` }, + { status: 409 }, + ); + } + return Response.json({ + skill: { slug: SLUG, displayName: SLUG, createdAt: 1, updatedAt: 2 }, + owner: { handle: ownerHandle, displayName: ownerHandle }, + }); + } + throw new Error(`unexpected ClawHub request: ${input}`); +} + +const callSkillsHandler = (method: string, params: Record) => + callGatewayHandler(skillsHandlers, method, params); + +describe("ClawHub publisher identity across skills.search, skills.detail, and skills.install", () => { + beforeEach(() => { + requestedUrls = []; + installSkillFromClawHubMock.mockReset(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: string | URL) => fakeClawHub(input instanceof URL ? input.href : input)), + ); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("gives each same-slug publisher its own install reference", async () => { + const { ok, response } = await callSkillsHandler("skills.search", { query: SLUG }); + + expect(ok).toBe(true); + expect( + (response as { results: { installRef?: string }[] }).results.map((r) => r.installRef), + ).toEqual([`@gzlicanyi/${SLUG}`, `@wangchenyu8/${SLUG}`, `skills-sh:acme/tools/${SLUG}`]); + }); + + it.each(PUBLISHERS)("reads detail for the selected publisher %s", async (ownerHandle) => { + const { ok, response, error } = await callSkillsHandler("skills.detail", { + slug: `@${ownerHandle}/${SLUG}`, + }); + + expect(error).toBeUndefined(); + expect(ok).toBe(true); + expect((response as { owner: { handle: string } }).owner.handle).toBe(ownerHandle); + const detailUrl = expectDefined( + requestedUrls.find((url) => url.includes(`/api/v1/skills/${SLUG}`)), + "detail request", + ); + expect(new URL(detailUrl).searchParams.get("ownerHandle")).toBe(ownerHandle); + }); + + it("surfaces the ambiguous-slug error instead of picking a publisher for a bare slug", async () => { + const { ok, error } = await callSkillsHandler("skills.detail", { slug: SLUG }); + + expect(ok).toBe(false); + expect(String((error as { message?: string }).message)).toContain("AMBIGUOUS_SKILL_SLUG"); + }); + + it("refuses external-source detail instead of reading a same-slug registry skill", async () => { + // Install keeps the external source, so a bare-slug read here would let an operator review + // one skill and install another. ClawHub has no source-qualified read endpoint yet. + const { ok, error } = await callSkillsHandler("skills.detail", { + slug: `skills-sh:openclaw/skills/${SLUG}`, + }); + + expect(ok).toBe(false); + expect((error as { code?: string }).code).toBe("INVALID_REQUEST"); + expect(requestedUrls.some((url) => url.includes("/api/v1/skills/"))).toBe(false); + }); + + it("forwards the selected publisher reference to the install lifecycle unchanged", async () => { + installSkillFromClawHubMock.mockResolvedValue({ + ok: true, + slug: SLUG, + version: "1.0.0", + targetDir: `/tmp/workspace/skills/${SLUG}`, + }); + + const { ok } = await callSkillsHandler("skills.install", { + source: "clawhub", + slug: `@wangchenyu8/${SLUG}`, + }); + + expect(ok).toBe(true); + expect(installSkillFromClawHubMock).toHaveBeenCalledWith( + expect.objectContaining({ slug: `@wangchenyu8/${SLUG}` }), + ); + }); +}); diff --git a/src/gateway/server-methods/skills.ts b/src/gateway/server-methods/skills.ts index 9c5b15f53777..4c729111f113 100644 --- a/src/gateway/server-methods/skills.ts +++ b/src/gateway/server-methods/skills.ts @@ -34,6 +34,7 @@ import { getOrCreatePromise } from "../../shared/lazy-promise.js"; import { updateSkillConfigEntry } from "../../skills/config/mutations.js"; import { collectSkillBins } from "../../skills/discovery/bins.js"; import { buildWorkspaceSkillStatus } from "../../skills/discovery/status.js"; +import { parseRequestedClawHubSkillRef } from "../../skills/lifecycle/clawhub-store.js"; import { installSkillFromClawHub, readLocalSkillCardContentSync, @@ -298,8 +299,26 @@ export const skillsHandlers: GatewayRequestHandlers = { return; } try { + // Same reference grammar as skills.install, so a client cannot review one publisher's + // card and then install another's. + const requested = parseRequestedClawHubSkillRef((params as { slug: string }).slug); + if (requested.requestedReference) { + // ClawHub has no source-qualified read endpoint, so reading this by bare slug would + // show a same-slug registry skill while install resolves the external artifact. + // Refusing keeps review and install on one identity until that contract exists. + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `ClawHub cannot return details for ${requested.requestedReference}; external skill sources are install-only. Install it directly, or run "openclaw skills install ${requested.requestedReference}".`, + ), + ); + return; + } const detail = await fetchClawHubSkillDetail({ - slug: (params as { slug: string }).slug, + slug: requested.slug, + ...(requested.ownerHandle ? { ownerHandle: requested.ownerHandle } : {}), }); respond(true, detail, undefined); } catch (err) { diff --git a/src/infra/clawhub-skills.test.ts b/src/infra/clawhub-skills.test.ts index d2cf0a96f105..b8450ac5e11d 100644 --- a/src/infra/clawhub-skills.test.ts +++ b/src/infra/clawhub-skills.test.ts @@ -84,6 +84,38 @@ describe("clawhub skills", () => { ).resolves.toMatchObject([{ icon: undefined }, { icon: undefined }]); }); + it("gives every search result the reference detail and install must send back", async () => { + const fetchImpl: typeof fetch = async () => + new Response( + JSON.stringify({ + results: [ + { score: 2, slug: "email", ownerHandle: "alice", displayName: "Email" }, + { score: 1, slug: "email", ownerHandle: "bob", displayName: "Email" }, + { score: 1, slug: "orphan", displayName: "Orphan" }, + { + score: 1, + slug: "weather", + installRef: "skills-sh:openclaw/skills/weather", + trustState: "not-scanned-by-clawhub", + displayName: "Weather", + }, + ], + }), + { headers: { "content-type": "application/json" } }, + ); + + await expect( + searchClawHubSkills({ query: "email", baseUrl: "https://registry.example", fetchImpl }).then( + (results) => results.map((entry) => entry.installRef), + ), + ).resolves.toEqual([ + "@alice/email", + "@bob/email", + undefined, + "skills-sh:openclaw/skills/weather", + ]); + }); + it("preserves the legacy telemetry opt-out when the primary env is blank", async () => { process.env.CLAWHUB_DISABLE_TELEMETRY = " "; process.env.CLAWDHUB_DISABLE_TELEMETRY = "true"; diff --git a/src/infra/clawhub-skills.ts b/src/infra/clawhub-skills.ts index 64e2ecbc4036..fff0dd7b1b20 100644 --- a/src/infra/clawhub-skills.ts +++ b/src/infra/clawhub-skills.ts @@ -23,6 +23,10 @@ export type ClawHubSkillsShTrustState = typeof CLAWHUB_SKILLS_SH_TRUST_STATE; export type ClawHubSkillSearchResult = { score: number; slug: string; + /** + * Reference every consumer must send back for detail and install. Search returns the same + * slug for several publishers, so the bare slug alone resolves to 409 AMBIGUOUS_SKILL_SLUG. + */ installRef?: string; trustState?: ClawHubSkillsShTrustState; // Search may return the same slug for multiple publishers; exact install refs need this handle. @@ -201,6 +205,12 @@ export async function searchClawHubSkills(params: { const results = result.results ?? []; for (const entry of results) { entry.icon = resolveClawHubImageUrl(entry.icon, params.baseUrl); + // Publisher identity is recorded once, here, so every consumer reads one reference instead + // of rebuilding it. Registry-supplied refs (skills.sh) already name their own source. + const ownerHandle = normalizeOptionalString(entry.ownerHandle); + if (!entry.installRef && ownerHandle) { + entry.installRef = `@${ownerHandle}/${entry.slug}`; + } } return results; } diff --git a/ui/src/i18n/.i18n/raw-copy-baseline.json b/ui/src/i18n/.i18n/raw-copy-baseline.json index 5eb050e342c5..09eab9b99e39 100644 --- a/ui/src/i18n/.i18n/raw-copy-baseline.json +++ b/ui/src/i18n/.i18n/raw-copy-baseline.json @@ -589,6 +589,13 @@ "path": "ui/src/pages/skill-workshop/view.ts", "text": "v" }, + { + "count": 1, + "kind": "object-property", + "name": "description", + "path": "ui/src/pages/skills/view.test-support.ts", + "text": "Skill description" + }, { "count": 1, "kind": "object-property", diff --git a/ui/src/lib/skills/clawhub-search.ts b/ui/src/lib/skills/clawhub-search.ts index 9ca44b56c571..1dd293952788 100644 --- a/ui/src/lib/skills/clawhub-search.ts +++ b/ui/src/lib/skills/clawhub-search.ts @@ -3,6 +3,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts"; export type ClawHubSearchResult = { score: number; slug: string; + installRef?: string; displayName: string; summary?: string; icon?: string | null; @@ -10,6 +11,15 @@ export type ClawHubSearchResult = { updatedAt?: number; }; +/** + * Reference the operator actually picked. Several publishers can share one slug, and ClawHub + * answers a bare slug with 409 AMBIGUOUS_SKILL_SLUG, so detail and install must send this. + * Gateways older than the installRef contract only supply the slug. + */ +export function clawHubSkillRef(result: ClawHubSearchResult): string { + return result.installRef ?? result.slug; +} + export async function searchClawHub( client: GatewayBrowserClient, query: string, diff --git a/ui/src/lib/skills/index.test.ts b/ui/src/lib/skills/index.test.ts index 76f8004450d2..30db5e353946 100644 --- a/ui/src/lib/skills/index.test.ts +++ b/ui/src/lib/skills/index.test.ts @@ -70,7 +70,7 @@ function createState(): { state: SkillsState; request: ReturnType { firstMethod: "skills.install", start: (state: SkillsState) => installFromClawHub(state, "github"), blocked: (state: SkillsState) => updateSkillEnabled(state, "calendar", true), - expectedMutation: { kind: "clawhub", slug: "github" } as const, + expectedMutation: { kind: "clawhub", ref: "github" } as const, }, ])("serializes $name and locks API key edits", async (fixture) => { const { state, request } = createState(); @@ -1288,7 +1288,7 @@ describe("skill mutations", () => { text: "Review the ClawHub warning before installing this skill.\n\n" + "REVIEW REQUIRED - ClawHub found suspicious behavior.", - acknowledgeSlug: "github", + acknowledgeRef: "github", acknowledgeVersion: "1.2.3", acknowledgeLabel: "Acknowledge risk and install", }); @@ -1373,7 +1373,7 @@ describe("reconcileSkillsAgentId", () => { managedSkillsDir: "/tmp/skills", skills: [], }; - state.skillOperation = { kind: "clawhub", slug: "calendar" }; + state.skillOperation = { kind: "clawhub", ref: "calendar" }; reconcileSkillsAgentId(state, { defaultId: "main", @@ -1385,7 +1385,7 @@ describe("reconcileSkillsAgentId", () => { expect(state.skillsAgentId).toBeNull(); expect(state.skillsAgentRevision).toBe(1); expect(state.skillsReport).toBeNull(); - expect(state.skillOperation).toEqual({ kind: "clawhub", slug: "calendar" }); + expect(state.skillOperation).toEqual({ kind: "clawhub", ref: "calendar" }); }); }); /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/lib/skills/index.ts b/ui/src/lib/skills/index.ts index a3455fec73b1..c91e54675846 100644 --- a/ui/src/lib/skills/index.ts +++ b/ui/src/lib/skills/index.ts @@ -91,13 +91,13 @@ type SkillsState = { clawhubSearchLoading: boolean; clawhubSearchError: string | null; clawhubDetail: ClawHubSkillDetail | null; - clawhubDetailSlug: string | null; + clawhubDetailRef: string | null; clawhubDetailLoading: boolean; clawhubDetailError: string | null; clawhubInstallMessage: { kind: "success" | "error"; text: string; - acknowledgeSlug?: string; + acknowledgeRef?: string; acknowledgeVersion?: string; acknowledgeLabel?: string; } | null; @@ -113,7 +113,7 @@ type SkillsState = { export type SkillOperation = | { kind: "refresh" } | { kind: "skill"; skillKey: string } - | { kind: "clawhub"; slug: string } + | { kind: "clawhub"; ref: string } | null; type ActiveSkillOperation = Exclude; @@ -637,13 +637,13 @@ export async function installSkill( }); } -export async function loadClawHubDetail(state: SkillsState, slug: string) { +export async function loadClawHubDetail(state: SkillsState, ref: string) { if (!state.client || !state.connected) { return; } const client = state.client; const agentScope = captureSkillsAgentScope(state); - state.clawhubDetailSlug = slug; + state.clawhubDetailRef = ref; state.clawhubDetailLoading = true; state.clawhubDetailError = null; state.clawhubDetail = null; @@ -651,9 +651,9 @@ export async function loadClawHubDetail(state: SkillsState, slug: string) { () => state.connected && state.client === client && - slug === state.clawhubDetailSlug && + ref === state.clawhubDetailRef && isSkillsAgentScopeCurrent(state, agentScope), - () => client.request("skills.detail", { slug }), + () => client.request("skills.detail", { slug: ref }), (res) => { state.clawhubDetail = res ?? null; }, @@ -667,7 +667,7 @@ export async function loadClawHubDetail(state: SkillsState, slug: string) { } export function closeClawHubDetail(state: SkillsState) { - state.clawhubDetailSlug = null; + state.clawhubDetailRef = null; state.clawhubDetail = null; state.clawhubDetailError = null; state.clawhubDetailLoading = false; @@ -675,7 +675,7 @@ export function closeClawHubDetail(state: SkillsState) { export async function installFromClawHub( state: SkillsState, - slug: string, + ref: string, acknowledgeClawHubRisk = false, version?: string, ) { @@ -684,14 +684,14 @@ export async function installFromClawHub( return; } const agentScope = captureSkillsAgentScope(state); - const operation = { kind: "clawhub", slug } as const; + const operation = { kind: "clawhub", ref } as const; state.skillOperation = operation; state.clawhubInstallMessage = null; try { const result = await client.request<{ message?: string; warning?: string }>("skills.install", { ...stateSkillsAgentParams(state), source: "clawhub", - slug, + slug: ref, ...(version ? { version } : {}), ...(acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), }); @@ -710,7 +710,7 @@ export async function installFromClawHub( } state.clawhubInstallMessage = { kind: "success", - text: formatClawHubInstallMessage(result?.message ?? `Installed ${slug}`, result?.warning), + text: formatClawHubInstallMessage(result?.message ?? `Installed ${ref}`, result?.warning), }; } catch (err) { if ( @@ -725,7 +725,7 @@ export async function installFromClawHub( text: needsAcknowledgement ? formatClawHubAcknowledgementMessage(trustDetails?.warning) : formatClawHubInstallMessage(formatUiError(err), trustDetails?.warning), - ...(needsAcknowledgement ? { acknowledgeSlug: slug } : {}), + ...(needsAcknowledgement ? { acknowledgeRef: ref } : {}), ...(needsAcknowledgement && trustDetails?.version ? { acknowledgeVersion: trustDetails.version } : {}), diff --git a/ui/src/pages/skills/skills-page.ts b/ui/src/pages/skills/skills-page.ts index 5cdbee24ef43..40d13f63138e 100644 --- a/ui/src/pages/skills/skills-page.ts +++ b/ui/src/pages/skills/skills-page.ts @@ -73,13 +73,13 @@ class SkillsPage extends OpenClawLightDomElement { @state() skillsDetailTab: SkillDetailTab = "overview"; @state() clawhubSearchQuery = ""; @state() clawhubDetail: ClawHubSkillDetail | null = null; - @state() clawhubDetailSlug: string | null = null; + @state() clawhubDetailRef: string | null = null; @state() clawhubDetailLoading = false; @state() clawhubDetailError: string | null = null; @state() clawhubInstallMessage: { kind: "success" | "error"; text: string; - acknowledgeSlug?: string; + acknowledgeRef?: string; acknowledgeVersion?: string; acknowledgeLabel?: string; } | null = null; @@ -184,7 +184,7 @@ class SkillsPage extends OpenClawLightDomElement { this.skillsDetailTab = "overview"; this.debouncedClawHubSearchQuery = ""; this.clawhubDetail = null; - this.clawhubDetailSlug = null; + this.clawhubDetailRef = null; this.clawhubDetailLoading = false; this.clawhubDetailError = null; this.clawhubInstallMessage = null; @@ -406,7 +406,7 @@ class SkillsPage extends OpenClawLightDomElement { clawhubSearchLoading: this.clawhubSearchLoading, clawhubSearchError: this.clawhubSearchError, clawhubDetail: this.clawhubDetail, - clawhubDetailSlug: this.clawhubDetailSlug, + clawhubDetailRef: this.clawhubDetailRef, clawhubDetailLoading: this.clawhubDetailLoading, clawhubDetailError: this.clawhubDetailError, clawhubInstallMessage: this.clawhubInstallMessage, @@ -441,11 +441,11 @@ class SkillsPage extends OpenClawLightDomElement { onDetailClose: () => (this.skillsDetailKey = null), onDetailTabChange: (tab) => this.changeDetailTab(tab), onClawHubQueryChange: (query) => this.changeClawHubQuery(query), - onClawHubDetailOpen: (slug) => void loadClawHubDetail(this, slug), + onClawHubDetailOpen: (ref) => void loadClawHubDetail(this, ref), onClawHubDetailClose: () => closeClawHubDetail(this), - onClawHubInstall: (slug, acknowledgeClawHubRisk, version) => { + onClawHubInstall: (ref, acknowledgeClawHubRisk, version) => { if (this.canInstallSkills()) { - void installFromClawHub(this, slug, acknowledgeClawHubRisk, version); + void installFromClawHub(this, ref, acknowledgeClawHubRisk, version); } }, })} diff --git a/ui/src/pages/skills/view.clawhub.test.ts b/ui/src/pages/skills/view.clawhub.test.ts new file mode 100644 index 000000000000..26121a313821 --- /dev/null +++ b/ui/src/pages/skills/view.clawhub.test.ts @@ -0,0 +1,461 @@ +/* @vitest-environment jsdom */ + +import { render } from "lit"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { SkillStatusReport } from "../../api/types.ts"; +import { i18n } from "../../i18n/index.ts"; +import { clawhubVerdictKey } from "../../lib/skills/index.ts"; +import { getRenderedModalDialog } from "../../test-helpers/modal-dialog.ts"; +import { + createDialogMethodInstaller, + createProps, + createSkill, + normalizeText, +} from "./view.test-support.ts"; +import { renderSkills } from "./view.ts"; + +const dialogRestores: Array<() => void> = []; +const installDialogMethod = createDialogMethodInstaller(dialogRestores); + +describe("renderSkills ClawHub", () => { + afterEach(async () => { + vi.restoreAllMocks(); + while (dialogRestores.length > 0) { + dialogRestores.pop()?.(); + } + await i18n.setLocale("en"); + }); + + it("opens detail dialogs and routes ClawHub actions", async () => { + const container = document.createElement("div"); + document.body.append(container); + dialogRestores.push(() => container.remove()); + const onDetailClose = vi.fn(); + const showModal = vi.fn(function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + const onClawHubDetailOpen = vi.fn(); + const onClawHubInstall = vi.fn(); + + installDialogMethod("showModal", showModal); + installDialogMethod("close", function (this: HTMLDialogElement) { + this.removeAttribute("open"); + this.dispatchEvent(new Event("close")); + }); + + render( + renderSkills( + createProps({ + detailKey: "repo-skill", + onDetailClose, + }), + ), + container, + ); + const { dialog } = await getRenderedModalDialog(container); + + expect(showModal).toHaveBeenCalledTimes(1); + expect(dialog.open).toBe(true); + + const closeButton = container.querySelector( + ".md-preview-dialog__header .btn", + ); + expect(closeButton).toBeInstanceOf(HTMLButtonElement); + closeButton!.click(); + + expect(onDetailClose).toHaveBeenCalledTimes(1); + + render( + renderSkills( + createProps({ + clawhubQuery: "git", + clawhubResults: [ + { + score: 0.95, + slug: "github", + displayName: "GitHub", + summary: "GitHub integration for OpenClaw", + icon: `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`, + version: "1.2.3", + }, + ], + onClawHubDetailOpen, + onClawHubInstall, + }), + ), + container, + ); + await Promise.resolve(); + + const resultItem = container.querySelector(".plugins-item"); + const detailButton = container.querySelector(".plugins-item__detail-button"); + const installButton = container.querySelector(".plugins-item .btn.btn--sm"); + expect(resultItem).toBeInstanceOf(HTMLElement); + expect(installButton).toBeInstanceOf(HTMLButtonElement); + expect(detailButton).toBeInstanceOf(HTMLButtonElement); + expect(detailButton?.getAttribute("aria-label")).toBe("Open github details"); + expect(detailButton?.contains(installButton)).toBe(false); + expect(resultItem?.querySelector(".settings-row__title")?.textContent?.trim()).toBe("GitHub"); + expect(resultItem?.querySelector(".settings-row__desc")?.textContent?.trim()).toBe( + "GitHub integration for OpenClaw · github", + ); + expect(resultItem?.querySelector(".settings-row__value")?.textContent?.trim()).toBe("v1.2.3"); + expect(resultItem?.querySelector(".clawhub-skill-icon")?.src).toBe( + `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`, + ); + expect(installButton?.textContent?.trim()).toBe("Install"); + detailButton!.click(); + installButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClawHubDetailOpen).toHaveBeenCalledTimes(1); + expect(onClawHubDetailOpen).toHaveBeenCalledWith("github"); + expect(onClawHubInstall).toHaveBeenCalledTimes(1); + expect(onClawHubInstall).toHaveBeenCalledWith("github"); + + onClawHubInstall.mockClear(); + showModal.mockClear(); + + render( + renderSkills( + createProps({ + clawhubSearchError: "rate limited", + clawhubInstallMessage: { kind: "success", text: "Installed github" }, + clawhubDetailRef: "github", + clawhubDetail: { + skill: { + slug: "github", + displayName: "GitHub", + summary: "GitHub integration for OpenClaw", + icon: `https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`, + createdAt: 1_700_000_000, + updatedAt: 1_700_000_100, + }, + latestVersion: { + version: "1.2.3", + createdAt: 1_700_000_200, + changelog: "Added search support", + }, + metadata: { + os: ["macos", "linux"], + }, + owner: { + displayName: "OpenClaw", + handle: "openclaw", + }, + }, + onClawHubInstall, + }), + ), + container, + ); + await Promise.resolve(); + + await vi.waitFor(() => expect(showModal).toHaveBeenCalledTimes(1)); + expect( + Array.from(container.querySelectorAll(".callout")).map((node) => normalizeText(node)), + ).toEqual(["rate limited", "Installed github"]); + expect(normalizeText(container.querySelector(".md-preview-dialog__body")!)).toBe( + "GitHub integration for OpenClaw By OpenClaw (@openclaw) Latest: v1.2.3 Added search support Platforms: macos, linux Install GitHub", + ); + expect(container.querySelector(".clawhub-skill-icon--detail")?.src).toBe( + `https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`, + ); + expect(container.querySelector(".clawhub-skill-icon--profile")).toBeNull(); + + const detailInstallButton = container.querySelector( + ".md-preview-dialog__body .btn.primary", + ); + expect(detailInstallButton).toBeInstanceOf(HTMLButtonElement); + detailInstallButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onClawHubInstall).toHaveBeenCalledTimes(1); + expect(onClawHubInstall).toHaveBeenCalledWith("github"); + }); + + it("routes each same-slug search result to its own publisher", async () => { + const container = document.createElement("div"); + document.body.append(container); + dialogRestores.push(() => container.remove()); + const onClawHubDetailOpen = vi.fn(); + const onClawHubInstall = vi.fn(); + + render( + renderSkills( + createProps({ + clawhubQuery: "imap-smtp-email", + clawhubResults: ["gzlicanyi", "wangchenyu8"].map((ownerHandle) => ({ + score: 1, + slug: "imap-smtp-email", + installRef: `@${ownerHandle}/imap-smtp-email`, + displayName: "imap-smtp-email", + })), + onClawHubDetailOpen, + onClawHubInstall, + }), + ), + container, + ); + await Promise.resolve(); + + const rows = [...container.querySelectorAll(".clawhub-skill-result__button")].map( + (button) => button.closest(".plugins-item")!, + ); + expect(rows).toHaveLength(2); + // Rows are otherwise identical, so the reference is what the operator reads and what the + // row actions must send; a bare slug here is the reported 409 AMBIGUOUS_SKILL_SLUG bug. + expect( + rows.map((row) => row.querySelector(".settings-row__desc")?.textContent?.trim()), + ).toEqual(["@gzlicanyi/imap-smtp-email", "@wangchenyu8/imap-smtp-email"]); + + for (const row of rows) { + row.querySelector(".plugins-item__detail-button")!.click(); + row + .querySelector(".btn.btn--sm")! + .dispatchEvent(new MouseEvent("click", { bubbles: true })); + } + + expect(onClawHubDetailOpen.mock.calls.flat()).toEqual([ + "@gzlicanyi/imap-smtp-email", + "@wangchenyu8/imap-smtp-email", + ]); + expect(onClawHubInstall.mock.calls.flat()).toEqual([ + "@gzlicanyi/imap-smtp-email", + "@wangchenyu8/imap-smtp-email", + ]); + }); + + it("sizes the ClawHub detail dialog to a refusal message instead of a reader", async () => { + const container = document.createElement("div"); + document.body.append(container); + dialogRestores.push(() => container.remove()); + + render( + renderSkills( + createProps({ + clawhubDetailRef: "skills-sh:acme/tools/imap-smtp-email", + clawhubDetailError: + "ClawHub cannot return details for skills-sh:acme/tools/imap-smtp-email; external skill sources are install-only.", + }), + ), + container, + ); + await Promise.resolve(); + + // Without this the panel keeps the tall reader height meant for skill documents, so a + // two-line refusal renders in a mostly empty dialog and reads as broken. + expect(container.querySelectorAll(".md-preview-dialog__panel--message-only")).toHaveLength(1); + }); + + it("renders ClawHub acknowledgement retry actions", async () => { + const container = document.createElement("div"); + document.body.append(container); + dialogRestores.push(() => container.remove()); + const onClawHubInstall = vi.fn(); + + render( + renderSkills( + createProps({ + clawhubInstallMessage: { + kind: "error", + text: "REVIEW REQUIRED - ClawHub found suspicious behavior.", + acknowledgeRef: "github", + acknowledgeVersion: "1.2.3", + }, + onClawHubInstall, + }), + ), + container, + ); + + const retryButton = container.querySelector(".callout button"); + expect(normalizeText(container.querySelector(".callout")!)).toBe( + "REVIEW REQUIRED - ClawHub found suspicious behavior. Acknowledge risk and install", + ); + expect(retryButton).toBeInstanceOf(HTMLButtonElement); + retryButton!.click(); + + expect(onClawHubInstall).toHaveBeenCalledTimes(1); + expect(onClawHubInstall).toHaveBeenCalledWith("github", true, "1.2.3"); + }); + + it("renders installed ClawHub verdicts and the local Skill Card tab", async () => { + const container = document.createElement("div"); + document.body.append(container); + dialogRestores.push(() => container.remove()); + installDialogMethod("showModal", function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + + const linkedSkill = createSkill({ + skillKey: "agentreceipt", + name: "AgentReceipt", + clawhub: { + status: "linked", + valid: true, + registry: "https://clawhub.ai", + slug: "agentreceipt", + ownerHandle: "openclaw", + installedVersion: "1.2.3", + installedAt: 123, + }, + skillCard: { + present: true, + path: "/tmp/workspace/skills/agentreceipt/skill-card.md", + sizeBytes: 30, + }, + }); + const report: SkillStatusReport = { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [linkedSkill], + }; + const verdictKey = clawhubVerdictKey({ + registry: "https://clawhub.ai", + slug: "agentreceipt", + ownerHandle: "openclaw", + version: "1.2.3", + }); + const onDetailTabChange = vi.fn(); + + render( + renderSkills( + createProps({ + report, + detailKey: "agentreceipt", + onDetailTabChange, + clawhubVerdicts: { + [verdictKey]: { + registry: "https://clawhub.ai", + ok: false, + decision: "fail", + reasons: ["security.suspicious"], + requestedSlug: "agentreceipt", + requestedOwnerHandle: "openclaw", + requestedVersion: "1.2.3", + slug: "agentreceipt", + version: "1.2.3", + securityAuditUrl: + "https://clawhub.ai/openclaw/skills/agentreceipt/security-audit?version=1.2.3", + securityStatus: "suspicious", + securityPassed: false, + }, + }, + }), + ), + container, + ); + await Promise.resolve(); + + expect(normalizeText(container)).toContain("Review"); + expect(normalizeText(container)).toContain("@openclaw/agentreceipt@1.2.3"); + expect(normalizeText(container)).toContain("security.suspicious"); + expect( + container.querySelector('a[href*="security-audit"]')?.textContent?.trim(), + ).toBe("Full security report"); + expect(container.querySelector("#skill-detail-tab-overview")?.hasAttribute("active")).toBe( + true, + ); + container + .querySelector("#skill-detail-tab-card") + ?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true })); + expect(onDetailTabChange).toHaveBeenCalledWith("card"); + + render( + renderSkills( + createProps({ + report, + detailKey: "agentreceipt", + detailTab: "card", + skillCardContents: { + agentreceipt: "# AgentReceipt\n\nLocal **trust** card.", + }, + clawhubVerdicts: { + [verdictKey]: { + registry: "https://clawhub.ai", + ok: false, + decision: "fail", + reasons: ["security.suspicious"], + requestedSlug: "agentreceipt", + requestedOwnerHandle: "openclaw", + requestedVersion: "1.2.3", + securityAuditUrl: + "https://clawhub.ai/openclaw/skills/agentreceipt/security-audit?version=1.2.3", + securityStatus: "suspicious", + securityPassed: false, + }, + }, + }), + ), + container, + ); + await Promise.resolve(); + + expect(container.querySelector("#skill-detail-tab-card")?.hasAttribute("active")).toBe(true); + expect(container.querySelector(".sidebar-markdown strong")?.textContent).toBe("trust"); + expect(normalizeText(container)).toContain("AgentReceipt Local trust card."); + }); + + it("fails closed for inconsistent ClawHub verdict envelopes", async () => { + const container = document.createElement("div"); + document.body.append(container); + dialogRestores.push(() => container.remove()); + installDialogMethod("showModal", function (this: HTMLDialogElement) { + this.setAttribute("open", ""); + }); + + const linkedSkill = createSkill({ + skillKey: "agentreceipt", + name: "AgentReceipt", + clawhub: { + status: "linked", + valid: true, + registry: "https://clawhub.ai", + slug: "agentreceipt", + installedVersion: "1.2.3", + installedAt: 123, + }, + }); + const report: SkillStatusReport = { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [linkedSkill], + }; + const verdictKey = clawhubVerdictKey({ + registry: "https://clawhub.ai", + slug: "agentreceipt", + version: "1.2.3", + }); + + render( + renderSkills( + createProps({ + report, + detailKey: "agentreceipt", + clawhubVerdicts: { + [verdictKey]: { + registry: "https://clawhub.ai", + ok: false, + decision: "pass", + reasons: [], + requestedSlug: "agentreceipt", + requestedVersion: "1.2.3", + slug: "agentreceipt", + version: "1.2.3", + securityStatus: "clean", + securityPassed: true, + }, + }, + }), + ), + container, + ); + await Promise.resolve(); + + const chips = Array.from(container.querySelectorAll(".chip")); + const verdictChip = chips.find((chip) => normalizeText(chip) === "Unavailable"); + expect(verdictChip).toBeDefined(); + expect(chips.map((chip) => normalizeText(chip))).toContain("Unavailable"); + expect(chips.some((chip) => normalizeText(chip) === "Clean")).toBe(false); + expect(verdictChip?.classList.contains("chip-ok")).toBe(false); + }); +}); diff --git a/ui/src/pages/skills/view.test-support.ts b/ui/src/pages/skills/view.test-support.ts new file mode 100644 index 000000000000..cf50ca18d524 --- /dev/null +++ b/ui/src/pages/skills/view.test-support.ts @@ -0,0 +1,137 @@ +import type { AgentsListResult, SkillStatusEntry, SkillStatusReport } from "../../api/types.ts"; +import type { renderSkills } from "./view.ts"; + +type SkillsProps = Parameters[0]; + +export function normalizeText(node: Element | DocumentFragment): string { + return node.textContent?.replace(/\s+/g, " ").trim() ?? ""; +} + +export function createSkill(overrides: Partial = {}): SkillStatusEntry { + return { + name: "Repo Skill", + description: "Skill description", + source: "workspace", + filePath: "/tmp/skill", + baseDir: "/tmp", + skillKey: "repo-skill", + bundled: false, + primaryEnv: "OPENAI_API_KEY", + emoji: undefined, + homepage: "https://example.com", + always: false, + disabled: false, + blockedByAllowlist: false, + blockedByAgentFilter: false, + eligible: true, + requirements: { + anyBins: [], + bins: [], + env: [], + config: [], + os: [], + }, + missing: { + anyBins: [], + bins: [], + env: [], + config: [], + os: [], + }, + configChecks: [], + install: [], + ...overrides, + }; +} + +export function createProps(overrides: Partial = {}): SkillsProps { + const report: SkillStatusReport = { + workspaceDir: "/tmp/workspace", + managedSkillsDir: "/tmp/skills", + skills: [createSkill()], + }; + const agentsList: AgentsListResult = { + defaultId: "main", + mainKey: "main", + scope: "per-sender", + agents: [ + { id: "main", name: "Main" }, + { id: "research", identity: { name: "Research", avatar: "R" } }, + ], + }; + + return { + canUpdate: true, + canInstall: true, + connected: true, + loading: false, + report, + agentsList, + selectedAgentId: "main", + error: null, + filter: "", + statusFilter: "all", + edits: {}, + operation: null, + messages: {}, + detailKey: null, + detailTab: "overview", + clawhubVerdicts: {}, + clawhubVerdictsLoading: false, + clawhubVerdictsError: null, + skillCardContents: {}, + skillCardLoadingKey: null, + skillCardErrors: {}, + clawhubQuery: "", + clawhubResults: null, + clawhubSearchLoading: false, + clawhubSearchError: null, + clawhubDetail: null, + clawhubDetailRef: null, + clawhubDetailLoading: false, + clawhubDetailError: null, + clawhubInstallMessage: null, + onAgentChange: () => undefined, + onFilterChange: () => undefined, + onStatusFilterChange: () => undefined, + onRefresh: () => undefined, + onToggle: () => undefined, + onEdit: () => undefined, + onSaveKey: () => undefined, + onInstall: () => undefined, + onDetailOpen: () => undefined, + onDetailClose: () => undefined, + onDetailTabChange: () => undefined, + onClawHubQueryChange: () => undefined, + onClawHubDetailOpen: () => undefined, + onClawHubDetailClose: () => undefined, + onClawHubInstall: () => undefined, + ...overrides, + }; +} + +/** + * Each split test file owns its own cleanup stack, so a patched dialog prototype from one file + * can never leak into the other when Vitest runs them in a shared environment. + */ +export function createDialogMethodInstaller(restores: Array<() => void>) { + return function installDialogMethod( + name: "showModal" | "close", + value: (this: HTMLDialogElement) => void, + ) { + const proto = HTMLDialogElement.prototype as HTMLDialogElement & Record; + const original = Object.getOwnPropertyDescriptor(proto, name); + Object.defineProperty(proto, name, { + configurable: true, + writable: true, + value, + }); + restores.push(() => { + if (original) { + Object.defineProperty(proto, name, original); + return; + } + delete proto[name]; + }); + }; +} diff --git a/ui/src/pages/skills/view.test.ts b/ui/src/pages/skills/view.test.ts index 62e9853552f8..1293808dbba1 100644 --- a/ui/src/pages/skills/view.test.ts +++ b/ui/src/pages/skills/view.test.ts @@ -3,122 +3,19 @@ import { expectDefined } from "@openclaw/normalization-core"; import { render } from "lit"; import { afterEach, describe, expect, it, vi } from "vitest"; -import type { AgentsListResult, SkillStatusEntry, SkillStatusReport } from "../../api/types.ts"; +import type { SkillStatusReport } from "../../api/types.ts"; import { i18n } from "../../i18n/index.ts"; -import { clawhubVerdictKey } from "../../lib/skills/index.ts"; import { getRenderedModalDialog } from "../../test-helpers/modal-dialog.ts"; +import { + createDialogMethodInstaller, + createProps, + createSkill, + normalizeText, +} from "./view.test-support.ts"; import { renderSkills } from "./view.ts"; -type SkillsProps = Parameters[0]; - const dialogRestores: Array<() => void> = []; - -function normalizeText(node: Element | DocumentFragment): string { - return node.textContent?.replace(/\s+/g, " ").trim() ?? ""; -} - -function createSkill(overrides: Partial = {}): SkillStatusEntry { - return { - name: "Repo Skill", - description: "Skill description", - source: "workspace", - filePath: "/tmp/skill", - baseDir: "/tmp", - skillKey: "repo-skill", - bundled: false, - primaryEnv: "OPENAI_API_KEY", - emoji: undefined, - homepage: "https://example.com", - always: false, - disabled: false, - blockedByAllowlist: false, - blockedByAgentFilter: false, - eligible: true, - requirements: { - anyBins: [], - bins: [], - env: [], - config: [], - os: [], - }, - missing: { - anyBins: [], - bins: [], - env: [], - config: [], - os: [], - }, - configChecks: [], - install: [], - ...overrides, - }; -} - -function createProps(overrides: Partial = {}): SkillsProps { - const report: SkillStatusReport = { - workspaceDir: "/tmp/workspace", - managedSkillsDir: "/tmp/skills", - skills: [createSkill()], - }; - const agentsList: AgentsListResult = { - defaultId: "main", - mainKey: "main", - scope: "per-sender", - agents: [ - { id: "main", name: "Main" }, - { id: "research", identity: { name: "Research", avatar: "R" } }, - ], - }; - - return { - canUpdate: true, - canInstall: true, - connected: true, - loading: false, - report, - agentsList, - selectedAgentId: "main", - error: null, - filter: "", - statusFilter: "all", - edits: {}, - operation: null, - messages: {}, - detailKey: null, - detailTab: "overview", - clawhubVerdicts: {}, - clawhubVerdictsLoading: false, - clawhubVerdictsError: null, - skillCardContents: {}, - skillCardLoadingKey: null, - skillCardErrors: {}, - clawhubQuery: "", - clawhubResults: null, - clawhubSearchLoading: false, - clawhubSearchError: null, - clawhubDetail: null, - clawhubDetailSlug: null, - clawhubDetailLoading: false, - clawhubDetailError: null, - clawhubInstallMessage: null, - onAgentChange: () => undefined, - onFilterChange: () => undefined, - onStatusFilterChange: () => undefined, - onRefresh: () => undefined, - onToggle: () => undefined, - onEdit: () => undefined, - onSaveKey: () => undefined, - onInstall: () => undefined, - onDetailOpen: () => undefined, - onDetailClose: () => undefined, - onDetailTabChange: () => undefined, - onClawHubQueryChange: () => undefined, - onClawHubDetailOpen: () => undefined, - onClawHubDetailClose: () => undefined, - onClawHubInstall: () => undefined, - ...overrides, - }; -} +const installDialogMethod = createDialogMethodInstaller(dialogRestores); describe("renderSkills", () => { afterEach(async () => { @@ -678,383 +575,4 @@ describe("renderSkills", () => { expect(showModal).toHaveBeenCalledTimes(1); expect(dialog.open).toBe(true); }); - - it("opens detail dialogs and routes ClawHub actions", async () => { - const container = document.createElement("div"); - document.body.append(container); - dialogRestores.push(() => container.remove()); - const onDetailClose = vi.fn(); - const showModal = vi.fn(function (this: HTMLDialogElement) { - this.setAttribute("open", ""); - }); - const onClawHubDetailOpen = vi.fn(); - const onClawHubInstall = vi.fn(); - - installDialogMethod("showModal", showModal); - installDialogMethod("close", function (this: HTMLDialogElement) { - this.removeAttribute("open"); - this.dispatchEvent(new Event("close")); - }); - - render( - renderSkills( - createProps({ - detailKey: "repo-skill", - onDetailClose, - }), - ), - container, - ); - const { dialog } = await getRenderedModalDialog(container); - - expect(showModal).toHaveBeenCalledTimes(1); - expect(dialog.open).toBe(true); - - const closeButton = container.querySelector( - ".md-preview-dialog__header .btn", - ); - expect(closeButton).toBeInstanceOf(HTMLButtonElement); - closeButton!.click(); - - expect(onDetailClose).toHaveBeenCalledTimes(1); - - render( - renderSkills( - createProps({ - clawhubQuery: "git", - clawhubResults: [ - { - score: 0.95, - slug: "github", - displayName: "GitHub", - summary: "GitHub integration for OpenClaw", - icon: `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`, - version: "1.2.3", - }, - ], - onClawHubDetailOpen, - onClawHubInstall, - }), - ), - container, - ); - await Promise.resolve(); - - const resultItem = container.querySelector(".plugins-item"); - const detailButton = container.querySelector(".plugins-item__detail-button"); - const installButton = container.querySelector(".plugins-item .btn.btn--sm"); - expect(resultItem).toBeInstanceOf(HTMLElement); - expect(installButton).toBeInstanceOf(HTMLButtonElement); - expect(detailButton).toBeInstanceOf(HTMLButtonElement); - expect(detailButton?.getAttribute("aria-label")).toBe("Open GitHub details"); - expect(detailButton?.contains(installButton)).toBe(false); - expect(resultItem?.querySelector(".settings-row__title")?.textContent?.trim()).toBe("GitHub"); - expect(resultItem?.querySelector(".settings-row__desc")?.textContent?.trim()).toBe( - "GitHub integration for OpenClaw", - ); - expect(resultItem?.querySelector(".settings-row__value")?.textContent?.trim()).toBe("v1.2.3"); - expect(resultItem?.querySelector(".clawhub-skill-icon")?.src).toBe( - `https://clawhub.ai/api/v1/skill-icons/${"a".repeat(64)}`, - ); - expect(installButton?.textContent?.trim()).toBe("Install"); - detailButton!.click(); - installButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - expect(onClawHubDetailOpen).toHaveBeenCalledTimes(1); - expect(onClawHubDetailOpen).toHaveBeenCalledWith("github"); - expect(onClawHubInstall).toHaveBeenCalledTimes(1); - expect(onClawHubInstall).toHaveBeenCalledWith("github"); - - onClawHubInstall.mockClear(); - showModal.mockClear(); - - render( - renderSkills( - createProps({ - clawhubSearchError: "rate limited", - clawhubInstallMessage: { kind: "success", text: "Installed github" }, - clawhubDetailSlug: "github", - clawhubDetail: { - skill: { - slug: "github", - displayName: "GitHub", - summary: "GitHub integration for OpenClaw", - icon: `https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`, - createdAt: 1_700_000_000, - updatedAt: 1_700_000_100, - }, - latestVersion: { - version: "1.2.3", - createdAt: 1_700_000_200, - changelog: "Added search support", - }, - metadata: { - os: ["macos", "linux"], - }, - owner: { - displayName: "OpenClaw", - handle: "openclaw", - }, - }, - onClawHubInstall, - }), - ), - container, - ); - await Promise.resolve(); - - await vi.waitFor(() => expect(showModal).toHaveBeenCalledTimes(1)); - expect( - Array.from(container.querySelectorAll(".callout")).map((node) => normalizeText(node)), - ).toEqual(["rate limited", "Installed github"]); - expect(normalizeText(container.querySelector(".md-preview-dialog__body")!)).toBe( - "GitHub integration for OpenClaw By OpenClaw (@openclaw) Latest: v1.2.3 Added search support Platforms: macos, linux Install GitHub", - ); - expect(container.querySelector(".clawhub-skill-icon--detail")?.src).toBe( - `https://clawhub.ai/api/v1/skill-icons/${"b".repeat(64)}`, - ); - expect(container.querySelector(".clawhub-skill-icon--profile")).toBeNull(); - - const detailInstallButton = container.querySelector( - ".md-preview-dialog__body .btn.primary", - ); - expect(detailInstallButton).toBeInstanceOf(HTMLButtonElement); - detailInstallButton!.dispatchEvent(new MouseEvent("click", { bubbles: true })); - - expect(onClawHubInstall).toHaveBeenCalledTimes(1); - expect(onClawHubInstall).toHaveBeenCalledWith("github"); - }); - - it("renders ClawHub acknowledgement retry actions", async () => { - const container = document.createElement("div"); - document.body.append(container); - dialogRestores.push(() => container.remove()); - const onClawHubInstall = vi.fn(); - - render( - renderSkills( - createProps({ - clawhubInstallMessage: { - kind: "error", - text: "REVIEW REQUIRED - ClawHub found suspicious behavior.", - acknowledgeSlug: "github", - acknowledgeVersion: "1.2.3", - }, - onClawHubInstall, - }), - ), - container, - ); - - const retryButton = container.querySelector(".callout button"); - expect(normalizeText(container.querySelector(".callout")!)).toBe( - "REVIEW REQUIRED - ClawHub found suspicious behavior. Acknowledge risk and install", - ); - expect(retryButton).toBeInstanceOf(HTMLButtonElement); - retryButton!.click(); - - expect(onClawHubInstall).toHaveBeenCalledTimes(1); - expect(onClawHubInstall).toHaveBeenCalledWith("github", true, "1.2.3"); - }); - - it("renders installed ClawHub verdicts and the local Skill Card tab", async () => { - const container = document.createElement("div"); - document.body.append(container); - dialogRestores.push(() => container.remove()); - installDialogMethod("showModal", function (this: HTMLDialogElement) { - this.setAttribute("open", ""); - }); - - const linkedSkill = createSkill({ - skillKey: "agentreceipt", - name: "AgentReceipt", - clawhub: { - status: "linked", - valid: true, - registry: "https://clawhub.ai", - slug: "agentreceipt", - ownerHandle: "openclaw", - installedVersion: "1.2.3", - installedAt: 123, - }, - skillCard: { - present: true, - path: "/tmp/workspace/skills/agentreceipt/skill-card.md", - sizeBytes: 30, - }, - }); - const report: SkillStatusReport = { - workspaceDir: "/tmp/workspace", - managedSkillsDir: "/tmp/skills", - skills: [linkedSkill], - }; - const verdictKey = clawhubVerdictKey({ - registry: "https://clawhub.ai", - slug: "agentreceipt", - ownerHandle: "openclaw", - version: "1.2.3", - }); - const onDetailTabChange = vi.fn(); - - render( - renderSkills( - createProps({ - report, - detailKey: "agentreceipt", - onDetailTabChange, - clawhubVerdicts: { - [verdictKey]: { - registry: "https://clawhub.ai", - ok: false, - decision: "fail", - reasons: ["security.suspicious"], - requestedSlug: "agentreceipt", - requestedOwnerHandle: "openclaw", - requestedVersion: "1.2.3", - slug: "agentreceipt", - version: "1.2.3", - securityAuditUrl: - "https://clawhub.ai/openclaw/skills/agentreceipt/security-audit?version=1.2.3", - securityStatus: "suspicious", - securityPassed: false, - }, - }, - }), - ), - container, - ); - await Promise.resolve(); - - expect(normalizeText(container)).toContain("Review"); - expect(normalizeText(container)).toContain("@openclaw/agentreceipt@1.2.3"); - expect(normalizeText(container)).toContain("security.suspicious"); - expect( - container.querySelector('a[href*="security-audit"]')?.textContent?.trim(), - ).toBe("Full security report"); - expect(container.querySelector("#skill-detail-tab-overview")?.hasAttribute("active")).toBe( - true, - ); - container - .querySelector("#skill-detail-tab-card") - ?.dispatchEvent(new MouseEvent("click", { detail: 1, bubbles: true })); - expect(onDetailTabChange).toHaveBeenCalledWith("card"); - - render( - renderSkills( - createProps({ - report, - detailKey: "agentreceipt", - detailTab: "card", - skillCardContents: { - agentreceipt: "# AgentReceipt\n\nLocal **trust** card.", - }, - clawhubVerdicts: { - [verdictKey]: { - registry: "https://clawhub.ai", - ok: false, - decision: "fail", - reasons: ["security.suspicious"], - requestedSlug: "agentreceipt", - requestedOwnerHandle: "openclaw", - requestedVersion: "1.2.3", - securityAuditUrl: - "https://clawhub.ai/openclaw/skills/agentreceipt/security-audit?version=1.2.3", - securityStatus: "suspicious", - securityPassed: false, - }, - }, - }), - ), - container, - ); - await Promise.resolve(); - - expect(container.querySelector("#skill-detail-tab-card")?.hasAttribute("active")).toBe(true); - expect(container.querySelector(".sidebar-markdown strong")?.textContent).toBe("trust"); - expect(normalizeText(container)).toContain("AgentReceipt Local trust card."); - }); - - it("fails closed for inconsistent ClawHub verdict envelopes", async () => { - const container = document.createElement("div"); - document.body.append(container); - dialogRestores.push(() => container.remove()); - installDialogMethod("showModal", function (this: HTMLDialogElement) { - this.setAttribute("open", ""); - }); - - const linkedSkill = createSkill({ - skillKey: "agentreceipt", - name: "AgentReceipt", - clawhub: { - status: "linked", - valid: true, - registry: "https://clawhub.ai", - slug: "agentreceipt", - installedVersion: "1.2.3", - installedAt: 123, - }, - }); - const report: SkillStatusReport = { - workspaceDir: "/tmp/workspace", - managedSkillsDir: "/tmp/skills", - skills: [linkedSkill], - }; - const verdictKey = clawhubVerdictKey({ - registry: "https://clawhub.ai", - slug: "agentreceipt", - version: "1.2.3", - }); - - render( - renderSkills( - createProps({ - report, - detailKey: "agentreceipt", - clawhubVerdicts: { - [verdictKey]: { - registry: "https://clawhub.ai", - ok: false, - decision: "pass", - reasons: [], - requestedSlug: "agentreceipt", - requestedVersion: "1.2.3", - slug: "agentreceipt", - version: "1.2.3", - securityStatus: "clean", - securityPassed: true, - }, - }, - }), - ), - container, - ); - await Promise.resolve(); - - const chips = Array.from(container.querySelectorAll(".chip")); - const verdictChip = chips.find((chip) => normalizeText(chip) === "Unavailable"); - expect(verdictChip).toBeDefined(); - expect(chips.map((chip) => normalizeText(chip))).toContain("Unavailable"); - expect(chips.some((chip) => normalizeText(chip) === "Clean")).toBe(false); - expect(verdictChip?.classList.contains("chip-ok")).toBe(false); - }); }); - -function installDialogMethod( - name: "showModal" | "close", - value: (this: HTMLDialogElement) => void, -) { - const proto = HTMLDialogElement.prototype as HTMLDialogElement & Record; - const original = Object.getOwnPropertyDescriptor(proto, name); - Object.defineProperty(proto, name, { - configurable: true, - writable: true, - value, - }); - dialogRestores.push(() => { - if (original) { - Object.defineProperty(proto, name, original); - return; - } - delete proto[name]; - }); -} diff --git a/ui/src/pages/skills/view.ts b/ui/src/pages/skills/view.ts index 19cb29371eb2..b611cbda6f9b 100644 --- a/ui/src/pages/skills/view.ts +++ b/ui/src/pages/skills/view.ts @@ -35,7 +35,7 @@ import { isSkillAvailable, renderSkillStatusChips, } from "../../lib/skills-shared.ts"; -import type { ClawHubSearchResult } from "../../lib/skills/clawhub-search.ts"; +import { clawHubSkillRef, type ClawHubSearchResult } from "../../lib/skills/clawhub-search.ts"; import { clawhubVerdictKey, type ClawHubSkillSecurityVerdict, @@ -81,13 +81,13 @@ type SkillsProps = { clawhubSearchLoading: boolean; clawhubSearchError: string | null; clawhubDetail: ClawHubSkillDetail | null; - clawhubDetailSlug: string | null; + clawhubDetailRef: string | null; clawhubDetailLoading: boolean; clawhubDetailError: string | null; clawhubInstallMessage: { kind: "success" | "error"; text: string; - acknowledgeSlug?: string; + acknowledgeRef?: string; acknowledgeVersion?: string; acknowledgeLabel?: string; } | null; @@ -103,9 +103,9 @@ type SkillsProps = { onDetailClose: () => void; onDetailTabChange: (tab: SkillDetailTab) => void; onClawHubQueryChange: (query: string) => void; - onClawHubDetailOpen: (slug: string) => void; + onClawHubDetailOpen: (ref: string) => void; onClawHubDetailClose: () => void; - onClawHubInstall: (slug: string, acknowledgeClawHubRisk?: boolean, version?: string) => void; + onClawHubInstall: (ref: string, acknowledgeClawHubRisk?: boolean, version?: string) => void; }; type StatusTabDef = { id: SkillsStatusFilter; labelKey: string }; @@ -225,8 +225,8 @@ function activeSkillMutation(props: SkillsProps, skillKey: string): boolean { return props.operation?.kind === "skill" && props.operation.skillKey === skillKey; } -function activeClawHubMutation(props: SkillsProps, slug: string): boolean { - return props.operation?.kind === "clawhub" && props.operation.slug === slug; +function activeClawHubMutation(props: SkillsProps, ref: string): boolean { + return props.operation?.kind === "clawhub" && props.operation.ref === ref; } export function renderSkills(props: SkillsProps) { @@ -286,7 +286,7 @@ export function renderSkills(props: SkillsProps) { { wide: true }, )} ${detailSkill ? renderSkillDetail(detailSkill, props) : nothing} - ${props.clawhubDetailSlug ? renderClawHubDetailDialog(props) : nothing} + ${props.clawhubDetailRef ? renderClawHubDetailDialog(props) : nothing} `; } @@ -421,7 +421,7 @@ function renderClawHubSection(props: SkillsProps) { > ${props.clawhubInstallMessage.text} - ${props.clawhubInstallMessage.acknowledgeSlug + ${props.clawhubInstallMessage.acknowledgeRef ? html` @@ -478,9 +481,9 @@ function renderClawHubResults(props: SkillsProps) { @@ -499,11 +502,15 @@ function renderClawHubDetailDialog(props: SkillsProps) { return html` -
+
${detailImageUrl @@ -516,7 +523,7 @@ function renderClawHubDetailDialog(props: SkillsProps) { />` : nothing}
- ${detail?.skill?.displayName ?? props.clawhubDetailSlug} + ${detail?.skill?.displayName ?? props.clawhubDetailRef}
diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index ddedfb4746e2..b606a11f7bc2 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -3950,6 +3950,12 @@ td.data-table-key-col { margin var(--duration-normal) var(--ease-out); } +/* A refusal or empty-state message is a few lines; without this the reader height reserved for + long skill documents leaves the dialog looking broken rather than deliberate. */ +.md-preview-dialog__panel--message-only { + min-height: 0; +} + .md-preview-dialog__header { display: flex; align-items: flex-start;