From aba94bbe0be5e594f8d03511c678fbdc88c92ac8 Mon Sep 17 00:00:00 2001 From: "Vyctor H. Brzezowski" Date: Thu, 13 Aug 2026 00:13:15 -0300 Subject: [PATCH] fix: install or review the publisher you picked when ClawHub skills share a slug (#121697) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(skills): keep ClawHub publisher identity from search through install ClawHub search returns one entry per publisher, so several results can share a slug. Every client collapsed the selection to that bare slug before calling skills.detail and skills.install, and ClawHub answered 409 AMBIGUOUS_SKILL_SLUG with no in-product way forward. searchClawHubSkills now records the publisher-qualified reference once, on the result that carries it, and the Gateway protocol documents it. skills.detail parses the same reference grammar skills.install already accepted, so review and install cannot resolve to different publishers. Control UI carries that one reference through row actions, detail, busy state, and acknowledgement retries, and shows it so otherwise identical rows are distinguishable. Fixes #117633 * fix(apps): send the ClawHub publisher reference from native skill browsers macOS, iOS, and Android read the qualified reference from search results and use it for skills.detail, install, busy state, installed matching, and list identity, so two publishers sharing a slug stay distinct instead of collapsing into one ambiguous request. * fix(skills): refuse external-source skill detail instead of reading a same-slug skill ClawHub has no source-qualified read endpoint, so a skills-sh reference parsed down to its bare slug would have returned a registry skill's card while install resolved the external artifact. Review and install could name different skills. skills.detail now fails closed on any reference that carries a source, and the macOS and AgentPro rows show the publisher reference next to the summary instead of only when a summary is missing, so same-slug rows stay distinguishable. * chore(apps): refresh native i18n source baseline for the skill row references * refactor(skills): drop the unread search-result ownerHandle field installRef is the one reference clients send back, and no client reads the publisher handle separately, so the protocol and Control UI carry one field instead of two. * fix(skills): name the next step when external skill detail is refused Clients that gate install behind a successful review would otherwise see only a refusal, so the error names the direct install path and the CLI equivalent. * fix(macos): use a doc comment on the ClawHub row subtitle swift-format's docComments rule requires doc comments on declarations; the subtitle property carried a regular comment and failed macos-swift. * fix(skills): carry ClawHub trust state to clients that can install Forwarding installRef let clients install the exact publisher the operator picked, including external skills-sh sources. It did not forward the trust state that says ClawHub never scanned that source, so iOS AgentPro — the one surface that installs in a single tap with no review step — could install an unscanned artifact with nothing on screen saying so. The CLI already labels these (docs/clawhub/cli.md, docs/cli/skills.md); native clients could not, because trustState was never on the wire. trustState becomes an optional field on SkillsSearchResultSchema. It is purely additive: older clients ignore an unknown key and the field is absent for registry results, so downgraded readers are unaffected and no protocol version moves. Every client that renders a search row now shows "Not scanned by ClawHub", matching the CLI wording exactly: iOS AgentPro in the row above the install button, macOS and Android beside the review action, and Control UI on the row that explains why review is refused for these sources. Covered by a wire assertion that the state reaches clients for an external source and stays absent for registry rows, plus decode-and-label tests on the shared Swift kit and the Android parser, and a Control UI render assertion. * fix(ui): size the ClawHub detail dialog to a refusal message Refusing detail for an external source made an error-only dialog reachable. The shared preview panel reserves a tall reader height for skill documents, so a two-line refusal rendered in a mostly empty dialog and read as broken rather than deliberate. Found by inspecting the review captures. * revert(ui,apps): drop the ClawHub trust label layer Maintainer product decision: skills.sh runs its own scanners, so OpenClaw does not add a second alert layer in the apps. Removes the label from Control UI, iOS, macOS and Android, and drops the trustState wire field that nothing would render. The CLI keeps its existing label; changing that is a separate call. Publisher identity, the fail-closed detail refusal, and the message-only dialog are unchanged. Splits the oversized skills view test file to satisfy max-lines without a suppression. * test(ui): fix ClawHub skill fixture checks * chore(plugin-sdk): refresh API baseline --------- Co-authored-by: Patrick Erichsen --- apps/.i18n/native-source.json | 188 +++---- .../main/java/ai/openclaw/app/NodeRuntime.kt | 8 +- .../java/ai/openclaw/app/SkillManagement.kt | 11 +- .../openclaw/app/ui/SkillsSettingsScreen.kt | 16 +- .../ai/openclaw/app/SkillManagementTest.kt | 20 +- apps/ios/Sources/Design/AgentProModels.swift | 6 + .../Sources/Design/AgentProTab+Skills.swift | 12 +- .../Design/SettingsSkillsDestination.swift | 16 +- .../OpenClaw/ClawHubSkillsBrowser.swift | 21 +- .../Sources/OpenClawKit/SkillManagement.swift | 9 +- .../SkillManagementTests.swift | 10 + .../agent-harness-runtime.json | 2 +- .../agent-harness.json | 2 +- .../plugin-sdk-api-baseline/channel-core.json | 2 +- .../channel-entry-contract.json | 2 +- .../channel-message.json | 2 +- .../channel-outbound.json | 2 +- .../channel-plugin-common.json | 2 +- .../plugin-sdk-api-baseline/core.json | 2 +- .../plugin-sdk-api-baseline/discord.json | 2 +- .../gateway-runtime.json | 2 +- .../inbound-reply-dispatch.json | 2 +- .../meeting-runtime.json | 2 +- .../plugin-sdk-api-baseline/plugin-entry.json | 2 +- .../plugin-runtime.json | 2 +- .../provider-catalog-runtime.json | 2 +- .../plugin-sdk-api-baseline/tool-plugin.json | 2 +- .../webhook-ingress.json | 2 +- .../src/schema/agents-models-skills.ts | 22 +- src/cli/skills-cli.commands.test.ts | 3 + src/cli/skills-cli.ts | 18 +- .../skills.publisher-identity.test.ts | 163 ++++++ src/gateway/server-methods/skills.ts | 21 +- src/infra/clawhub-skills.test.ts | 32 ++ src/infra/clawhub-skills.ts | 10 + ui/src/i18n/.i18n/raw-copy-baseline.json | 7 + ui/src/lib/skills/clawhub-search.ts | 10 + ui/src/lib/skills/index.test.ts | 10 +- ui/src/lib/skills/index.ts | 26 +- ui/src/pages/skills/skills-page.ts | 14 +- ui/src/pages/skills/view.clawhub.test.ts | 461 ++++++++++++++++ ui/src/pages/skills/view.test-support.ts | 137 +++++ ui/src/pages/skills/view.test.ts | 498 +----------------- ui/src/pages/skills/view.ts | 49 +- ui/src/styles/components.css | 6 + 45 files changed, 1139 insertions(+), 699 deletions(-) create mode 100644 src/gateway/server-methods/skills.publisher-identity.test.ts create mode 100644 ui/src/pages/skills/view.clawhub.test.ts create mode 100644 ui/src/pages/skills/view.test-support.ts 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;