fix: install or review the publisher you picked when ClawHub skills share a slug (#121697)

* 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 <patrick.a.erichsen@gmail.com>
This commit is contained in:
Vyctor H. Brzezowski
2026-08-13 00:13:15 -03:00
committed by GitHub
parent f5ad8735d1
commit aba94bbe0b
45 changed files with 1139 additions and 699 deletions
+94 -94
View File
@@ -1301,17 +1301,17 @@
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 6384, "line": 6384,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "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", "surface": "android",
"id": "native.android.57c31cd4bd7c5146" "id": "native.android.e5b0558c26183ff4"
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 6400, "line": 6400,
"path": "apps/android/app/src/main/java/ai/openclaw/app/NodeRuntime.kt", "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", "surface": "android",
"id": "native.android.ecf82b1ac9d18307" "id": "native.android.5d4e8b19fbd1204f"
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
@@ -1827,7 +1827,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 120, "line": 129,
"path": "apps/android/app/src/main/java/ai/openclaw/app/SkillManagement.kt", "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.", "source": "The Gateway evaluated a different ClawHub release. Review the skill again before installing.",
"surface": "android", "surface": "android",
@@ -1835,7 +1835,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 232, "line": 241,
"path": "apps/android/app/src/main/java/ai/openclaw/app/SkillManagement.kt", "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.", "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", "surface": "android",
@@ -10979,7 +10979,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 606, "line": 608,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Version $it", "source": "Version $it",
"surface": "android", "surface": "android",
@@ -10987,7 +10987,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 615, "line": 621,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Installing", "source": "Installing",
"surface": "android", "surface": "android",
@@ -10995,7 +10995,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 616, "line": 622,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Loading", "source": "Loading",
"surface": "android", "surface": "android",
@@ -11003,7 +11003,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 676, "line": 682,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Needs attention", "source": "Needs attention",
"surface": "android", "surface": "android",
@@ -11011,7 +11011,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 714, "line": 720,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Review", "source": "Review",
"surface": "android", "surface": "android",
@@ -11019,7 +11019,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 720, "line": 726,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Dismiss", "source": "Dismiss",
"surface": "android", "surface": "android",
@@ -11027,7 +11027,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 727, "line": 733,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Acknowledge Gateway warning and install", "source": "Acknowledge Gateway warning and install",
"surface": "android", "surface": "android",
@@ -11035,7 +11035,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 746, "line": 752,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Review ClawHub skill", "source": "Review ClawHub skill",
"surface": "android", "surface": "android",
@@ -11043,7 +11043,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 753, "line": 759,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Version", "source": "Version",
"surface": "android", "surface": "android",
@@ -11051,7 +11051,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 754, "line": 760,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Publisher", "source": "Publisher",
"surface": "android", "surface": "android",
@@ -11059,7 +11059,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 756, "line": 762,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "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.", "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", "surface": "android",
@@ -11067,7 +11067,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 764, "line": 770,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Verify and install", "source": "Verify and install",
"surface": "android", "surface": "android",
@@ -11075,7 +11075,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 769, "line": 775,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Cancel", "source": "Cancel",
"surface": "android", "surface": "android",
@@ -11083,7 +11083,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 811, "line": 817,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "All", "source": "All",
"surface": "android", "surface": "android",
@@ -11091,7 +11091,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 813, "line": 819,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Needs Setup", "source": "Needs Setup",
"surface": "android", "surface": "android",
@@ -11099,7 +11099,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 830, "line": 836,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Off", "source": "Off",
"surface": "android", "surface": "android",
@@ -11107,7 +11107,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 831, "line": 837,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Setup", "source": "Setup",
"surface": "android", "surface": "android",
@@ -11115,7 +11115,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 832, "line": 838,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Ready", "source": "Ready",
"surface": "android", "surface": "android",
@@ -11123,7 +11123,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 845, "line": 851,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Disabled", "source": "Disabled",
"surface": "android", "surface": "android",
@@ -11131,7 +11131,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 846, "line": 852,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Blocked", "source": "Blocked",
"surface": "android", "surface": "android",
@@ -11139,7 +11139,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 847, "line": 853,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Not available to this agent", "source": "Not available to this agent",
"surface": "android", "surface": "android",
@@ -11147,7 +11147,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 849, "line": 855,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Needs setup", "source": "Needs setup",
"surface": "android", "surface": "android",
@@ -11155,7 +11155,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 857, "line": 863,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "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.", "source": "This skill is disabled on the gateway. Enable it here when the current connection has operator.admin.",
"surface": "android", "surface": "android",
@@ -11163,7 +11163,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 858, "line": 864,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "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.", "source": "This skill is blocked by the gateway allowlist. Allowlist changes stay on desktop or CLI.",
"surface": "android", "surface": "android",
@@ -11171,7 +11171,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 859, "line": 865,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "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.", "source": "This skill is installed but not available to the current agent. Agent filters stay on desktop or CLI.",
"surface": "android", "surface": "android",
@@ -11179,7 +11179,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 861, "line": 867,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "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.", "source": "This skill is installed but not currently eligible to run. Use desktop or CLI for configuration changes.",
"surface": "android", "surface": "android",
@@ -11187,7 +11187,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 862, "line": 868,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "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.", "source": "Ready on this gateway. Android can enable or disable it globally; setup and configuration stay on desktop or CLI.",
"surface": "android", "surface": "android",
@@ -11195,7 +11195,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 867, "line": 873,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "No missing items", "source": "No missing items",
"surface": "android", "surface": "android",
@@ -11203,7 +11203,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 868, "line": 874,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "1 missing item", "source": "1 missing item",
"surface": "android", "surface": "android",
@@ -11211,7 +11211,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 869, "line": 875,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "$count missing items", "source": "$count missing items",
"surface": "android", "surface": "android",
@@ -11219,7 +11219,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 874, "line": 880,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "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.", "source": "This skill needs 1 setup item. Android shows what is installed; setup/config changes stay on desktop or CLI.",
"surface": "android", "surface": "android",
@@ -11227,7 +11227,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 875, "line": 881,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "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.", "source": "This skill needs $count setup items. Android shows what is installed; setup/config changes stay on desktop or CLI.",
"surface": "android", "surface": "android",
@@ -11235,7 +11235,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 880, "line": 886,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Built-in", "source": "Built-in",
"surface": "android", "surface": "android",
@@ -11243,7 +11243,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 880, "line": 886,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Bundled", "source": "Bundled",
"surface": "android", "surface": "android",
@@ -11251,7 +11251,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 881, "line": 887,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Installed", "source": "Installed",
"surface": "android", "surface": "android",
@@ -11259,7 +11259,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 882, "line": 888,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Workspace", "source": "Workspace",
"surface": "android", "surface": "android",
@@ -11267,7 +11267,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 883, "line": 889,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Extra", "source": "Extra",
"surface": "android", "surface": "android",
@@ -11275,7 +11275,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 884, "line": 890,
"path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt", "path": "apps/android/app/src/main/java/ai/openclaw/app/ui/SkillsSettingsScreen.kt",
"source": "Skill", "source": "Skill",
"surface": "android", "surface": "android",
@@ -18659,7 +18659,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 186, "line": 188,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Install %@", "source": "Install %@",
"surface": "apple", "surface": "apple",
@@ -18667,7 +18667,7 @@
}, },
{ {
"kind": "ui-named-argument", "kind": "ui-named-argument",
"line": 194, "line": 196,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Installed Skills", "source": "Installed Skills",
"surface": "apple", "surface": "apple",
@@ -18675,7 +18675,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 200, "line": 202,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "No skills found", "source": "No skills found",
"surface": "apple", "surface": "apple",
@@ -18683,7 +18683,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 200, "line": 202,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Skills unavailable", "source": "Skills unavailable",
"surface": "apple", "surface": "apple",
@@ -18691,7 +18691,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 202, "line": 204,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Try a different search or refresh from the gateway.", "source": "Try a different search or refresh from the gateway.",
"surface": "apple", "surface": "apple",
@@ -18699,7 +18699,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 203, "line": 205,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Connect a gateway to load workspace skills.", "source": "Connect a gateway to load workspace skills.",
"surface": "apple", "surface": "apple",
@@ -18707,7 +18707,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 315, "line": 317,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Setup: %@", "source": "Setup: %@",
"surface": "apple", "surface": "apple",
@@ -18715,7 +18715,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 337, "line": 339,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Set up %@", "source": "Set up %@",
"surface": "apple", "surface": "apple",
@@ -18723,7 +18723,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 349, "line": 351,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Edit %@", "source": "Edit %@",
"surface": "apple", "surface": "apple",
@@ -18731,7 +18731,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 353, "line": 355,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "saving", "source": "saving",
"surface": "apple", "surface": "apple",
@@ -18739,7 +18739,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 416, "line": 418,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Skill unavailable", "source": "Skill unavailable",
"surface": "apple", "surface": "apple",
@@ -18747,7 +18747,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 418, "line": 420,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Return to the skills list and choose another skill.", "source": "Return to the skills list and choose another skill.",
"surface": "apple", "surface": "apple",
@@ -18755,7 +18755,7 @@
}, },
{ {
"kind": "ui-modifier", "kind": "ui-modifier",
"line": 425, "line": 427,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Skill", "source": "Skill",
"surface": "apple", "surface": "apple",
@@ -18763,7 +18763,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 460, "line": 462,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Close", "source": "Close",
"surface": "apple", "surface": "apple",
@@ -18771,7 +18771,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 480, "line": 482,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Workspace skill", "source": "Workspace skill",
"surface": "apple", "surface": "apple",
@@ -18779,7 +18779,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 495, "line": 497,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Enabled globally", "source": "Enabled globally",
"surface": "apple", "surface": "apple",
@@ -18787,7 +18787,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 505, "line": 507,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "API key", "source": "API key",
"surface": "apple", "surface": "apple",
@@ -18795,7 +18795,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 513, "line": 515,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Save key", "source": "Save key",
"surface": "apple", "surface": "apple",
@@ -18803,7 +18803,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 520, "line": 522,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Get key", "source": "Get key",
"surface": "apple", "surface": "apple",
@@ -18811,7 +18811,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 574, "line": 576,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Off", "source": "Off",
"surface": "apple", "surface": "apple",
@@ -18819,7 +18819,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 574, "line": 576,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "On", "source": "On",
"surface": "apple", "surface": "apple",
@@ -18827,7 +18827,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 580, "line": 582,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Setup", "source": "Setup",
"surface": "apple", "surface": "apple",
@@ -18835,7 +18835,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 584, "line": 586,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Missing: %@", "source": "Missing: %@",
"surface": "apple", "surface": "apple",
@@ -18843,7 +18843,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 589, "line": 591,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "No missing requirements reported.", "source": "No missing requirements reported.",
"surface": "apple", "surface": "apple",
@@ -18851,7 +18851,7 @@
}, },
{ {
"kind": "ui-named-argument", "kind": "ui-named-argument",
"line": 612, "line": 614,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Key", "source": "Key",
"surface": "apple", "surface": "apple",
@@ -18859,7 +18859,7 @@
}, },
{ {
"kind": "ui-named-argument", "kind": "ui-named-argument",
"line": 613, "line": 615,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Source", "source": "Source",
"surface": "apple", "surface": "apple",
@@ -18867,7 +18867,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 696, "line": 698,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Skill policy reset.", "source": "Skill policy reset.",
"surface": "apple", "surface": "apple",
@@ -18875,7 +18875,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 696, "line": 698,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Skill policy saved.", "source": "Skill policy saved.",
"surface": "apple", "surface": "apple",
@@ -18883,7 +18883,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 709, "line": 711,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Skill disabled.", "source": "Skill disabled.",
"surface": "apple", "surface": "apple",
@@ -18891,7 +18891,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 709, "line": 711,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "Skill enabled.", "source": "Skill enabled.",
"surface": "apple", "surface": "apple",
@@ -18899,7 +18899,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 721, "line": 723,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "API key cleared.", "source": "API key cleared.",
"surface": "apple", "surface": "apple",
@@ -18907,7 +18907,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 722, "line": 724,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "API key saved.", "source": "API key saved.",
"surface": "apple", "surface": "apple",
@@ -18915,7 +18915,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 877, "line": 879,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "off", "source": "off",
"surface": "apple", "surface": "apple",
@@ -18923,7 +18923,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 879, "line": 881,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "blocked", "source": "blocked",
"surface": "apple", "surface": "apple",
@@ -18931,7 +18931,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 881, "line": 883,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "disabled", "source": "disabled",
"surface": "apple", "surface": "apple",
@@ -18939,7 +18939,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 883, "line": 885,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "setup", "source": "setup",
"surface": "apple", "surface": "apple",
@@ -18947,7 +18947,7 @@
}, },
{ {
"kind": "ui-localized-call", "kind": "ui-localized-call",
"line": 885, "line": 887,
"path": "apps/ios/Sources/Design/AgentProTab+Skills.swift", "path": "apps/ios/Sources/Design/AgentProTab+Skills.swift",
"source": "enabled", "source": "enabled",
"surface": "apple", "surface": "apple",
@@ -30763,7 +30763,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 138, "line": 145,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "Installed", "source": "Installed",
"surface": "apple", "surface": "apple",
@@ -30771,7 +30771,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 138, "line": 145,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "Review", "source": "Review",
"surface": "apple", "surface": "apple",
@@ -30779,7 +30779,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 153, "line": 160,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "Review ClawHub skill", "source": "Review ClawHub skill",
"surface": "apple", "surface": "apple",
@@ -30787,7 +30787,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 155, "line": 162,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "The Gateway will verify this exact release with ClawHub before download.", "source": "The Gateway will verify this exact release with ClawHub before download.",
"surface": "apple", "surface": "apple",
@@ -30795,7 +30795,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 161, "line": 168,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "Verify and install", "source": "Verify and install",
"surface": "apple", "surface": "apple",
@@ -30803,7 +30803,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 182, "line": 189,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "Gateway warning", "source": "Gateway warning",
"surface": "apple", "surface": "apple",
@@ -30811,7 +30811,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 187, "line": 194,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "Review warning details", "source": "Review warning details",
"surface": "apple", "surface": "apple",
@@ -30819,7 +30819,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 193, "line": 200,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "Expand and review the Gateway warning before acknowledging this exact version.", "source": "Expand and review the Gateway warning before acknowledging this exact version.",
"surface": "apple", "surface": "apple",
@@ -30827,7 +30827,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 198, "line": 205,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "Cancel", "source": "Cancel",
"surface": "apple", "surface": "apple",
@@ -30835,7 +30835,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 199, "line": 206,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "Acknowledge and install", "source": "Acknowledge and install",
"surface": "apple", "surface": "apple",
@@ -30843,7 +30843,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 218, "line": 225,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "Version", "source": "Version",
"surface": "apple", "surface": "apple",
@@ -30851,7 +30851,7 @@
}, },
{ {
"kind": "ui-call", "kind": "ui-call",
"line": 219, "line": 226,
"path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift", "path": "apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift",
"source": "Publisher", "source": "Publisher",
"surface": "apple", "surface": "apple",
@@ -43075,7 +43075,7 @@
}, },
{ {
"kind": "conditional-branch", "kind": "conditional-branch",
"line": 348, "line": 355,
"path": "apps/shared/OpenClawKit/Sources/OpenClawKit/SkillManagement.swift", "path": "apps/shared/OpenClawKit/Sources/OpenClawKit/SkillManagement.swift",
"source": "The Gateway evaluated a different ClawHub release. Review the skill again before installing.", "source": "The Gateway evaluated a different ClawHub release. Review the skill again before installing.",
"surface": "apple", "surface": "apple",
@@ -6362,7 +6362,7 @@ class NodeRuntime private constructor(
publishGatewayData(gatewayScope) { publishGatewayData(gatewayScope) {
_clawHubSkillSearchState.value = _clawHubSkillSearchState.value =
_clawHubSkillSearchState.value.copy( _clawHubSkillSearchState.value.copy(
reviewingSlug = skill.slug, reviewingSlug = skill.reference,
installReview = null, installReview = null,
acknowledgeSlug = null, acknowledgeSlug = null,
acknowledgeVersion = null, acknowledgeVersion = null,
@@ -6371,7 +6371,7 @@ class NodeRuntime private constructor(
) )
} }
try { 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) val review = parseClawHubInstallReview(response, skill, json)
publishGatewayData(gatewayScope) { publishGatewayData(gatewayScope) {
if (clawHubSkillReviewSeq.get() == reviewSeq) { if (clawHubSkillReviewSeq.get() == reviewSeq) {
@@ -6381,7 +6381,7 @@ class NodeRuntime private constructor(
installReview = review, installReview = review,
errorText = errorText =
if (review == null) { if (review == null) {
"ClawHub did not return an installable version for ${skill.slug}." "ClawHub did not return an installable version for ${skill.reference}."
} else { } else {
null null
}, },
@@ -6397,7 +6397,7 @@ class NodeRuntime private constructor(
_clawHubSkillSearchState.value.copy( _clawHubSkillSearchState.value.copy(
reviewingSlug = null, reviewingSlug = null,
errorText = errorText =
nativeString("Could not load ClawHub details for \${skill.slug}.", skill.slug), nativeString("Could not load ClawHub details for \${skill.reference}.", skill.reference),
) )
} }
} }
@@ -28,10 +28,18 @@ data class GatewayClawHubSkillSearchState(
data class GatewayClawHubSkillSummary( data class GatewayClawHubSkillSummary(
val slug: String, val slug: String,
val installRef: String?,
val displayName: String, val displayName: String,
val summary: String?, val summary: String?,
val version: 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( data class GatewayClawHubInstallReview(
val slug: String, val slug: String,
@@ -60,6 +68,7 @@ internal fun parseClawHubSearchResults(
val displayName = value.string("displayName") ?: return@mapNotNull null val displayName = value.string("displayName") ?: return@mapNotNull null
GatewayClawHubSkillSummary( GatewayClawHubSkillSummary(
slug = slug, slug = slug,
installRef = value.string("installRef"),
displayName = displayName, displayName = displayName,
summary = value.string("summary"), summary = value.string("summary"),
version = value.string("version"), version = value.string("version"),
@@ -599,15 +599,21 @@ private fun ClawHubSkillSearchPanel(
if (state.results.isNotEmpty()) { if (state.results.isNotEmpty()) {
ClawListPanel(items = state.results) { skill -> ClawListPanel(items = state.results) { skill ->
val installed = val installed =
skill.version?.let { version -> isClawHubSkillInstalled(installedSkills, skill.slug, version) } skill.version?.let { version -> isClawHubSkillInstalled(installedSkills, skill.reference, version) }
?: isClawHubSkillInstalled(installedSkills, skill.slug) ?: isClawHubSkillInstalled(installedSkills, skill.reference)
val subtitleParts =
listOfNotNull(
skill.summary,
skill.reference,
skill.version?.let { nativeString("Version \$it", it) },
)
ClawDetailRow( ClawDetailRow(
title = skill.displayName, title = skill.displayName,
subtitle = listOfNotNull(skill.summary, skill.version?.let { nativeString("Version \$it", it) }).joinToString(" · "), subtitle = subtitleParts.joinToString(" · "),
leading = { ClawTextBadge(text = skillBadge(skill.displayName)) }, leading = { ClawTextBadge(text = skillBadge(skill.displayName)) },
trailing = { trailing = {
val reviewing = state.reviewingSlug == skill.slug val reviewing = state.reviewingSlug == skill.reference
val installing = isClawHubSkillOperationActive(state.installingSlugs, skill.slug) val installing = isClawHubSkillOperationActive(state.installingSlugs, skill.reference)
ClawSecondaryButton( ClawSecondaryButton(
text = text =
when { when {
@@ -20,7 +20,7 @@ class SkillManagementTest {
fun searchResultsKeepOnlyIdentifiedSkills() { fun searchResultsKeepOnlyIdentifiedSkills() {
val results = val results =
parseClawHubSearchResults( 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, json,
) )
@@ -28,6 +28,7 @@ class SkillManagementTest {
listOf( listOf(
GatewayClawHubSkillSummary( GatewayClawHubSkillSummary(
slug = "alpha", slug = "alpha",
installRef = "@alice/alpha",
displayName = "Alpha", displayName = "Alpha",
summary = "Useful", summary = "Useful",
version = "1.2.3", 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 @Test
fun detailBindsExactVersionAndPublisherIdentity() { fun detailBindsExactVersionAndPublisherIdentity() {
val review = val review =
parseClawHubInstallReview( parseClawHubInstallReview(
"""{"skill":{"displayName":"Alpha Skill","summary":"Reviewed metadata"},"latestVersion":{"version":"2.0.0"},"owner":{"displayName":"Alice","handle":"alice"}}""", """{"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, json,
) )
@@ -63,7 +75,7 @@ class SkillManagementTest {
val review = val review =
parseClawHubInstallReview( parseClawHubInstallReview(
"""{"skill":{"displayName":"Alpha"},"latestVersion":{"version":"2.0.0"},"owner":{"handle":"alice"}}""", """{"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, json,
) )
@@ -75,7 +87,7 @@ class SkillManagementTest {
val review = val review =
parseClawHubInstallReview( parseClawHubInstallReview(
"""{"skill":{"displayName":"Alpha"},"owner":{"handle":"alice"}}""", """{"skill":{"displayName":"Alpha"},"owner":{"handle":"alice"}}""",
GatewayClawHubSkillSummary("alpha", "Alpha", null, null), GatewayClawHubSkillSummary("alpha", null, "Alpha", null, null),
json, json,
) )
@@ -263,9 +263,15 @@ struct ClawHubSearchResponseLite: Decodable {
struct ClawHubSearchResultLite: Decodable { struct ClawHubSearchResultLite: Decodable {
let slug: String let slug: String
let installRef: String?
let displayName: String let displayName: String
let summary: String? let summary: String?
let version: 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 { struct ClawHubInstallParams: Encodable {
@@ -146,7 +146,7 @@ extension AgentProTab {
if !self.clawHubResults.isEmpty { if !self.clawHubResults.isEmpty {
VStack(spacing: 0) { VStack(spacing: 0) {
let results = Array(self.clawHubResults.prefix(8)) 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) self.clawHubResultRow(result)
if index < results.count - 1 { if index < results.count - 1 {
Divider().padding(.leading, 42) Divider().padding(.leading, 42)
@@ -160,14 +160,16 @@ extension AgentProTab {
} }
func clawHubResultRow(_ result: ClawHubSearchResultLite) -> some View { func clawHubResultRow(_ result: ClawHubSearchResultLite) -> some View {
let installing = clawHubInstallSlug == result.slug let installing = clawHubInstallSlug == result.reference
return HStack(alignment: .top, spacing: 10) { return HStack(alignment: .top, spacing: 10) {
ProIconBadge(systemName: "sparkles", color: OpenClawBrand.accent) ProIconBadge(systemName: "sparkles", color: OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text(result.displayName) Text(result.displayName)
.font(OpenClawType.subheadSemiBold) .font(OpenClawType.subheadSemiBold)
.lineLimit(1) .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) .font(OpenClawType.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.lineLimit(2) .lineLimit(2)
@@ -741,11 +743,11 @@ extension AgentProTab {
@MainActor @MainActor
func installClawHubSkill(_ result: ClawHubSearchResultLite) async { func installClawHubSkill(_ result: ClawHubSearchResultLite) async {
guard liveGatewayConnected else { return } guard liveGatewayConnected else { return }
clawHubInstallSlug = result.slug clawHubInstallSlug = result.reference
clawHubErrorText = nil clawHubErrorText = nil
defer { self.clawHubInstallSlug = nil } defer { self.clawHubInstallSlug = nil }
do { do {
let params = ClawHubInstallParams(slug: result.slug) let params = ClawHubInstallParams(slug: result.reference)
_ = try await self.requestGateway(method: "skills.install", params: params, timeoutSeconds: 125) _ = try await self.requestGateway(method: "skills.install", params: params, timeoutSeconds: 125)
await appModel.refreshGatewayOverviewIfConnected() await appModel.refreshGatewayOverviewIfConnected()
await refreshOverview(force: true) await refreshOverview(force: true)
@@ -387,10 +387,10 @@ struct SettingsSkillsDestination: View {
ClawHubSkillRow( ClawHubSkillRow(
skill: skill, skill: skill,
installed: skill.version.map { installed: skill.version.map {
SkillManagementContract.installed(self.installedSkills, slug: skill.slug, version: $0) SkillManagementContract.installed(self.installedSkills, slug: skill.reference, version: $0)
} ?? SkillManagementContract.installed(self.installedSkills, slug: skill.slug), } ?? SkillManagementContract.installed(self.installedSkills, slug: skill.reference),
isBusy: self.reviewingSlug == skill.slug || self.installingSlug.map { isBusy: self.reviewingSlug == skill.reference || self.installingSlug.map {
SkillManagementContract.sameClawHubSkill($0, skill.slug) SkillManagementContract.sameClawHubSkill($0, skill.reference)
} == true, } == true,
onReview: { Task { await self.review(skill) } }) onReview: { Task { await self.review(skill) } })
} }
@@ -523,7 +523,7 @@ struct SettingsSkillsDestination: View {
let gatewayID = self.appModel.connectedGatewayID let gatewayID = self.appModel.connectedGatewayID
let operationID = UUID() let operationID = UUID()
self.reviewID = operationID self.reviewID = operationID
self.reviewingSlug = skill.slug self.reviewingSlug = skill.reference
self.notice = nil self.notice = nil
defer { defer {
if self.reviewID == operationID { if self.reviewID == operationID {
@@ -535,7 +535,7 @@ struct SettingsSkillsDestination: View {
let route = try await gatewayRoute() let route = try await gatewayRoute()
let data = try await request( let data = try await request(
method: "skills.detail", method: "skills.detail",
params: ClawHubDetailRequest(slug: skill.slug), params: ClawHubDetailRequest(slug: skill.reference),
timeoutSeconds: 20, timeoutSeconds: 20,
route: route) route: route)
let detail = try JSONDecoder().decode(ClawHubSkillDetail.self, from: data) 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) ProIconBadge(systemName: "shippingbox", color: self.installed ? OpenClawBrand.ok : OpenClawBrand.accent)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(self.skill.displayName).font(OpenClawType.subheadSemiBold) Text(self.skill.displayName).font(OpenClawType.subheadSemiBold)
Text(self.skill.summary ?? self.skill.slug) Text(self.skill.summary ?? self.skill.reference)
.font(OpenClawType.caption) .font(OpenClawType.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
HStack(spacing: 6) { 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 { if let version = self.skill.version {
Text(verbatim: version).font(OpenClawType.monoSmall).foregroundStyle(.secondary) Text(verbatim: version).font(OpenClawType.monoSmall).foregroundStyle(.secondary)
} }
@@ -66,13 +66,13 @@ struct ClawHubSkillsBrowser: View {
installed: skill.version.map { installed: skill.version.map {
SkillManagementContract.installed( SkillManagementContract.installed(
self.installedSkills, self.installedSkills,
slug: skill.slug, slug: skill.reference,
version: $0) version: $0)
} ?? SkillManagementContract.installed( } ?? SkillManagementContract.installed(
self.installedSkills, self.installedSkills,
slug: skill.slug), slug: skill.reference),
isBusy: self.model.reviewingSlug == skill.slug || self.model.installingSlug.map { isBusy: self.model.reviewingSlug == skill.reference || self.model.installingSlug.map {
SkillManagementContract.sameClawHubSkill($0, skill.slug) SkillManagementContract.sameClawHubSkill($0, skill.reference)
} == true, } == true,
showsDivider: index != self.model.results.count - 1) showsDivider: index != self.model.results.count - 1)
{ {
@@ -124,10 +124,17 @@ private struct ClawHubSkillResultRow: View {
let showsDivider: Bool let showsDivider: Bool
let onReview: () -> Void 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 { var body: some View {
SettingsCardRow( SettingsCardRow(
title: .verbatim(self.skill.displayName), title: .verbatim(self.skill.displayName),
subtitle: .verbatim(self.skill.summary ?? self.skill.slug), subtitle: .verbatim(self.subtitle),
showsDivider: self.showsDivider) showsDivider: self.showsDivider)
{ {
if let version = self.skill.version { if let version = self.skill.version {
@@ -288,14 +295,14 @@ private final class ClawHubSkillsBrowserModel {
func review(_ skill: ClawHubSkillSummary) async { func review(_ skill: ClawHubSkillSummary) async {
guard self.reviewingSlug == nil else { return } guard self.reviewingSlug == nil else { return }
self.reviewingSlug = skill.slug self.reviewingSlug = skill.reference
self.notice = nil self.notice = nil
defer { self.reviewingSlug = nil } defer { self.reviewingSlug = nil }
do { do {
guard let route = await GatewayConnection.shared.captureRoute() else { guard let route = await GatewayConnection.shared.captureRoute() else {
throw ClawHubSkillsBrowserError.gatewayUnavailable 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 { guard let review = ClawHubSkillInstallReview(detail: detail, fallback: skill) else {
throw ClawHubSkillsBrowserError.missingInstallVersion throw ClawHubSkillsBrowserError.missingInstallVersion
} }
@@ -220,12 +220,19 @@ public struct ClawHubInstalledSkillLink: Codable, Sendable {
public struct ClawHubSkillSummary: Codable, Identifiable, Hashable, Sendable { public struct ClawHubSkillSummary: Codable, Identifiable, Hashable, Sendable {
public let slug: String public let slug: String
public let installRef: String?
public let displayName: String public let displayName: String
public let summary: String? public let summary: String?
public let version: 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 { public var id: String {
self.slug self.reference
} }
} }
@@ -22,6 +22,16 @@ struct SkillManagementTests {
#expect(review.author == "Molly") #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`() { @Test func `risk acknowledgement stays bound to reviewed version`() {
let matching = GatewayResponseError( let matching = GatewayResponseError(
method: "skills.install", method: "skills.install",
@@ -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"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"4f73f681d60fd6eed1abe5e4d542c456798e5221fa2c97dcd480d1f33a4b1ecc","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"} {"contentHash":"6b6c13f1d3006b6fb7c08bddd514d32ff35b9eb3df87d5add52bc5a48eb958ec","entrypoint":"agent-harness","importSpecifier":"openclaw/plugin-sdk/agent-harness"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"a5062fea6e891209423fcfeae5a88c92695f672d80181b646951879e0613e911","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"} {"contentHash":"fe51d5aeedac8e00896266f2939fd45faed0743a0a9c18215f0a3ec42c58fa2e","entrypoint":"channel-core","importSpecifier":"openclaw/plugin-sdk/channel-core"}
@@ -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"}
@@ -1 +1 @@
{"contentHash":"74285633ffd2690a31f53c3b612b74aef87e887935e11e84e9898e48f4db5d75","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"} {"contentHash":"9cdddd8b710598fd4d042eb5c3ad35d50efe873aba6021754d0d68f07cdb5f37","entrypoint":"channel-message","importSpecifier":"openclaw/plugin-sdk/channel-message"}
@@ -1 +1 @@
{"contentHash":"331508088936dad14f0caef18bf8d26d51e6db76febaa4045567b97212cff647","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"} {"contentHash":"feda02ab30be6ddc116227f65614720d6da9ded7cfb393164c9bdf6d397b0554","entrypoint":"channel-outbound","importSpecifier":"openclaw/plugin-sdk/channel-outbound"}
@@ -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"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"90014e82f746dd9cdfad10b81132431e34da5eb7b06f489e70affc4e3dac5921","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"} {"contentHash":"a67fc659b7a00b0ba664f0c3ed032507ca280169aef2d5168b7e484fa6e7359a","entrypoint":"core","importSpecifier":"openclaw/plugin-sdk/core"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"e40b030c61bbdcf0c7c15620a47ff88a2d0e9aac8d363c019459cece7dfff47c","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"} {"contentHash":"e0b7667e42591bf8df977daa460aeb108b123af63159646b8a681b1eb51cce58","entrypoint":"discord","importSpecifier":"openclaw/plugin-sdk/discord"}
@@ -1 +1 @@
{"contentHash":"084e6289f1636eb48c6f6ef99c3f51ac862708bbb9a837f9d50edcece6987080","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"} {"contentHash":"a3eed0ce9e6224b14b8eff90bd4333078efc5c6509b70a144e6a243a554acb61","entrypoint":"gateway-runtime","importSpecifier":"openclaw/plugin-sdk/gateway-runtime"}
@@ -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"}
@@ -1 +1 @@
{"contentHash":"f643813855b0359515d0faf0c58b9ef323399e5ba7ffced29ef81305051d66aa","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"} {"contentHash":"9acb37896771b9fa94638e07a7c59cbcb7c28f98f4f9c6cea50212ff25b13ffa","entrypoint":"meeting-runtime","importSpecifier":"openclaw/plugin-sdk/meeting-runtime"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"0f9097423b1b563db164cab7cc7e1f9d1eaed13eb12ce16523a72d8b1614375b","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"} {"contentHash":"6fb970713ffc85efd0478c1a8ad2ce71946cf9426c9f28c0025b196f03d87cd7","entrypoint":"plugin-entry","importSpecifier":"openclaw/plugin-sdk/plugin-entry"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"cdb8cb034a2e2f4cb2e511b706c964ddc2704761d17feceb2999fa41e87feea7","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"} {"contentHash":"03bce874a07e9d0197227386465d4f7fe8d125209df56d73b4ebac1b9d814a8c","entrypoint":"plugin-runtime","importSpecifier":"openclaw/plugin-sdk/plugin-runtime"}
@@ -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"}
+1 -1
View File
@@ -1 +1 @@
{"contentHash":"3f9b816df61c93c1e3e78245451f69878bc53d194c6737f6d4770728387a9061","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"} {"contentHash":"fe1c2291373e523397e96b4d2c92e55f5ce138937a00d6fa49b2e9a9af5490f0","entrypoint":"tool-plugin","importSpecifier":"openclaw/plugin-sdk/tool-plugin"}
@@ -1 +1 @@
{"contentHash":"c7d16f543fc05054a0dc3b9b3206b9ba383f18244631ce83d4bf7c46ee26dab3","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"} {"contentHash":"3211b4cfd054e6df3673507cc886616fa06180c3b7951e4693bd54f389a233e4","entrypoint":"webhook-ingress","importSpecifier":"openclaw/plugin-sdk/webhook-ingress"}
@@ -356,6 +356,14 @@ export const SkillsUploadCommitParamsSchema = closedObject({
sha256: Type.Optional(Sha256String), 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. */ /** Installs a skill from legacy install id, ClawHub, or uploaded archive. */
export const SkillsInstallParamsSchema = Type.Union([ export const SkillsInstallParamsSchema = Type.Union([
closedObject({ closedObject({
@@ -374,7 +382,7 @@ export const SkillsInstallParamsSchema = Type.Union([
closedObject({ closedObject({
agentId: Type.Optional(NonEmptyString), agentId: Type.Optional(NonEmptyString),
source: Type.Literal("clawhub"), source: Type.Literal("clawhub"),
slug: NonEmptyString, slug: Type.String({ minLength: 1, description: CLAWHUB_SKILL_REF_DESCRIPTION }),
version: Type.Optional(NonEmptyString), version: Type.Optional(NonEmptyString),
force: Type.Optional(Type.Boolean()), force: Type.Optional(Type.Boolean()),
acknowledgeClawHubRisk: Type.Optional(Type.Boolean()), acknowledgeClawHubRisk: Type.Optional(Type.Boolean()),
@@ -420,17 +428,25 @@ export const SkillsSearchResultSchema = closedObject({
closedObject({ closedObject({
score: Type.Number(), score: Type.Number(),
slug: NonEmptyString, 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, displayName: NonEmptyString,
summary: Type.Optional(Type.String()), summary: Type.Optional(Type.String()),
icon: Type.Optional(Type.Union([Type.String(), Type.Null()])),
version: Type.Optional(NonEmptyString), version: Type.Optional(NonEmptyString),
updatedAt: Type.Optional(Type.Integer()), updatedAt: Type.Optional(Type.Integer()),
}), }),
), ),
}); });
/** Reads registry detail for one skill slug. */ /** Reads registry detail for one skill. */
export const SkillsDetailParamsSchema = closedObject({ export const SkillsDetailParamsSchema = closedObject({
slug: NonEmptyString, slug: Type.String({ minLength: 1, description: CLAWHUB_SKILL_REF_DESCRIPTION }),
}); });
/** Reads current security verdicts for configured skills. */ /** Reads current security verdicts for configured skills. */
+3
View File
@@ -401,6 +401,7 @@ describe("skills cli commands", () => {
{ {
slug: "calendar", slug: "calendar",
ownerHandle: "demo-owner", ownerHandle: "demo-owner",
installRef: "@demo-owner/calendar",
displayName: "Calendar", displayName: "Calendar",
summary: "CalDAV helpers", summary: "CalDAV helpers",
version: "1.2.3", version: "1.2.3",
@@ -408,6 +409,7 @@ describe("skills cli commands", () => {
{ {
slug: "calendar", slug: "calendar",
ownerHandle: "work-owner", ownerHandle: "work-owner",
installRef: "@work-owner/calendar",
displayName: "Team Calendar", displayName: "Team Calendar",
}, },
]); ]);
@@ -464,6 +466,7 @@ describe("skills cli commands", () => {
{ {
slug: "oauth-helper", slug: "oauth-helper",
ownerHandle: "demo-owner", ownerHandle: "demo-owner",
installRef: "@demo-owner/oauth-helper",
displayName: "Oauth\nHelper", displayName: "Oauth\nHelper",
summary: summary:
"Automate OAuth login flows.\nSupports multiple providers.\n\nFeatures:\n- Confirm before authorizing", "Automate OAuth login flows.\nSupports multiple providers.\n\nFeatures:\n- Confirm before authorizing",
+5 -13
View File
@@ -595,22 +595,14 @@ export function registerSkillsCli(program: Command) {
} }
for (const entry of results) { for (const entry of results) {
const installRef = normalizeOptionalString(entry.installRef); const installRef = normalizeOptionalString(entry.installRef);
const ownerHandle = normalizeOptionalString(entry.ownerHandle); const skillRef = formatClawHubSearchText(installRef ?? entry.slug);
const slug = formatClawHubSearchText(entry.slug); const isExternalSource =
const skillsShInstallRef = installRef?.startsWith("skills-sh:") === true &&
installRef?.startsWith("skills-sh:") && entry.trustState === CLAWHUB_SKILLS_SH_TRUST_STATE;
entry.trustState === CLAWHUB_SKILLS_SH_TRUST_STATE
? installRef
: undefined;
const skillRef = skillsShInstallRef
? formatClawHubSearchText(skillsShInstallRef)
: ownerHandle
? `@${formatClawHubSearchText(ownerHandle)}/${slug}`
: slug;
const version = entry.version ? ` v${formatClawHubSearchText(entry.version)}` : ""; const version = entry.version ? ` v${formatClawHubSearchText(entry.version)}` : "";
const summary = entry.summary ? ` ${formatClawHubSearchText(entry.summary)}` : ""; const summary = entry.summary ? ` ${formatClawHubSearchText(entry.summary)}` : "";
const displayName = formatClawHubSearchText(entry.displayName); 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}`); defaultRuntime.log(`${skillRef}${version} ${displayName}${summary}${trust}`);
} }
} catch (err) { } catch (err) {
@@ -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<typeof import("../../skills/lifecycle/clawhub.js")>()),
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<string, unknown>) =>
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}` }),
);
});
});
+20 -1
View File
@@ -34,6 +34,7 @@ import { getOrCreatePromise } from "../../shared/lazy-promise.js";
import { updateSkillConfigEntry } from "../../skills/config/mutations.js"; import { updateSkillConfigEntry } from "../../skills/config/mutations.js";
import { collectSkillBins } from "../../skills/discovery/bins.js"; import { collectSkillBins } from "../../skills/discovery/bins.js";
import { buildWorkspaceSkillStatus } from "../../skills/discovery/status.js"; import { buildWorkspaceSkillStatus } from "../../skills/discovery/status.js";
import { parseRequestedClawHubSkillRef } from "../../skills/lifecycle/clawhub-store.js";
import { import {
installSkillFromClawHub, installSkillFromClawHub,
readLocalSkillCardContentSync, readLocalSkillCardContentSync,
@@ -298,8 +299,26 @@ export const skillsHandlers: GatewayRequestHandlers = {
return; return;
} }
try { 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({ const detail = await fetchClawHubSkillDetail({
slug: (params as { slug: string }).slug, slug: requested.slug,
...(requested.ownerHandle ? { ownerHandle: requested.ownerHandle } : {}),
}); });
respond(true, detail, undefined); respond(true, detail, undefined);
} catch (err) { } catch (err) {
+32
View File
@@ -84,6 +84,38 @@ describe("clawhub skills", () => {
).resolves.toMatchObject([{ icon: undefined }, { icon: undefined }]); ).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 () => { it("preserves the legacy telemetry opt-out when the primary env is blank", async () => {
process.env.CLAWHUB_DISABLE_TELEMETRY = " "; process.env.CLAWHUB_DISABLE_TELEMETRY = " ";
process.env.CLAWDHUB_DISABLE_TELEMETRY = "true"; process.env.CLAWDHUB_DISABLE_TELEMETRY = "true";
+10
View File
@@ -23,6 +23,10 @@ export type ClawHubSkillsShTrustState = typeof CLAWHUB_SKILLS_SH_TRUST_STATE;
export type ClawHubSkillSearchResult = { export type ClawHubSkillSearchResult = {
score: number; score: number;
slug: string; 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; installRef?: string;
trustState?: ClawHubSkillsShTrustState; trustState?: ClawHubSkillsShTrustState;
// Search may return the same slug for multiple publishers; exact install refs need this handle. // 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 ?? []; const results = result.results ?? [];
for (const entry of results) { for (const entry of results) {
entry.icon = resolveClawHubImageUrl(entry.icon, params.baseUrl); 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; return results;
} }
+7
View File
@@ -589,6 +589,13 @@
"path": "ui/src/pages/skill-workshop/view.ts", "path": "ui/src/pages/skill-workshop/view.ts",
"text": "v" "text": "v"
}, },
{
"count": 1,
"kind": "object-property",
"name": "description",
"path": "ui/src/pages/skills/view.test-support.ts",
"text": "Skill description"
},
{ {
"count": 1, "count": 1,
"kind": "object-property", "kind": "object-property",
+10
View File
@@ -3,6 +3,7 @@ import type { GatewayBrowserClient } from "../../api/gateway.ts";
export type ClawHubSearchResult = { export type ClawHubSearchResult = {
score: number; score: number;
slug: string; slug: string;
installRef?: string;
displayName: string; displayName: string;
summary?: string; summary?: string;
icon?: string | null; icon?: string | null;
@@ -10,6 +11,15 @@ export type ClawHubSearchResult = {
updatedAt?: number; 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( export async function searchClawHub(
client: GatewayBrowserClient, client: GatewayBrowserClient,
query: string, query: string,
+5 -5
View File
@@ -70,7 +70,7 @@ function createState(): { state: SkillsState; request: ReturnType<typeof vi.fn<T
clawhubSearchLoading: false, clawhubSearchLoading: false,
clawhubSearchError: "old error", clawhubSearchError: "old error",
clawhubDetail: null, clawhubDetail: null,
clawhubDetailSlug: null, clawhubDetailRef: null,
clawhubDetailLoading: false, clawhubDetailLoading: false,
clawhubDetailError: null, clawhubDetailError: null,
clawhubInstallMessage: null, clawhubInstallMessage: null,
@@ -996,7 +996,7 @@ describe("skill mutations", () => {
firstMethod: "skills.install", firstMethod: "skills.install",
start: (state: SkillsState) => installFromClawHub(state, "github"), start: (state: SkillsState) => installFromClawHub(state, "github"),
blocked: (state: SkillsState) => updateSkillEnabled(state, "calendar", true), 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) => { ])("serializes $name and locks API key edits", async (fixture) => {
const { state, request } = createState(); const { state, request } = createState();
@@ -1288,7 +1288,7 @@ describe("skill mutations", () => {
text: text:
"Review the ClawHub warning before installing this skill.\n\n" + "Review the ClawHub warning before installing this skill.\n\n" +
"REVIEW REQUIRED - ClawHub found suspicious behavior.", "REVIEW REQUIRED - ClawHub found suspicious behavior.",
acknowledgeSlug: "github", acknowledgeRef: "github",
acknowledgeVersion: "1.2.3", acknowledgeVersion: "1.2.3",
acknowledgeLabel: "Acknowledge risk and install", acknowledgeLabel: "Acknowledge risk and install",
}); });
@@ -1373,7 +1373,7 @@ describe("reconcileSkillsAgentId", () => {
managedSkillsDir: "/tmp/skills", managedSkillsDir: "/tmp/skills",
skills: [], skills: [],
}; };
state.skillOperation = { kind: "clawhub", slug: "calendar" }; state.skillOperation = { kind: "clawhub", ref: "calendar" };
reconcileSkillsAgentId(state, { reconcileSkillsAgentId(state, {
defaultId: "main", defaultId: "main",
@@ -1385,7 +1385,7 @@ describe("reconcileSkillsAgentId", () => {
expect(state.skillsAgentId).toBeNull(); expect(state.skillsAgentId).toBeNull();
expect(state.skillsAgentRevision).toBe(1); expect(state.skillsAgentRevision).toBe(1);
expect(state.skillsReport).toBeNull(); 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. */ /* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+13 -13
View File
@@ -91,13 +91,13 @@ type SkillsState = {
clawhubSearchLoading: boolean; clawhubSearchLoading: boolean;
clawhubSearchError: string | null; clawhubSearchError: string | null;
clawhubDetail: ClawHubSkillDetail | null; clawhubDetail: ClawHubSkillDetail | null;
clawhubDetailSlug: string | null; clawhubDetailRef: string | null;
clawhubDetailLoading: boolean; clawhubDetailLoading: boolean;
clawhubDetailError: string | null; clawhubDetailError: string | null;
clawhubInstallMessage: { clawhubInstallMessage: {
kind: "success" | "error"; kind: "success" | "error";
text: string; text: string;
acknowledgeSlug?: string; acknowledgeRef?: string;
acknowledgeVersion?: string; acknowledgeVersion?: string;
acknowledgeLabel?: string; acknowledgeLabel?: string;
} | null; } | null;
@@ -113,7 +113,7 @@ type SkillsState = {
export type SkillOperation = export type SkillOperation =
| { kind: "refresh" } | { kind: "refresh" }
| { kind: "skill"; skillKey: string } | { kind: "skill"; skillKey: string }
| { kind: "clawhub"; slug: string } | { kind: "clawhub"; ref: string }
| null; | null;
type ActiveSkillOperation = Exclude<SkillOperation, null>; type ActiveSkillOperation = Exclude<SkillOperation, null>;
@@ -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) { if (!state.client || !state.connected) {
return; return;
} }
const client = state.client; const client = state.client;
const agentScope = captureSkillsAgentScope(state); const agentScope = captureSkillsAgentScope(state);
state.clawhubDetailSlug = slug; state.clawhubDetailRef = ref;
state.clawhubDetailLoading = true; state.clawhubDetailLoading = true;
state.clawhubDetailError = null; state.clawhubDetailError = null;
state.clawhubDetail = null; state.clawhubDetail = null;
@@ -651,9 +651,9 @@ export async function loadClawHubDetail(state: SkillsState, slug: string) {
() => () =>
state.connected && state.connected &&
state.client === client && state.client === client &&
slug === state.clawhubDetailSlug && ref === state.clawhubDetailRef &&
isSkillsAgentScopeCurrent(state, agentScope), isSkillsAgentScopeCurrent(state, agentScope),
() => client.request<ClawHubSkillDetail>("skills.detail", { slug }), () => client.request<ClawHubSkillDetail>("skills.detail", { slug: ref }),
(res) => { (res) => {
state.clawhubDetail = res ?? null; state.clawhubDetail = res ?? null;
}, },
@@ -667,7 +667,7 @@ export async function loadClawHubDetail(state: SkillsState, slug: string) {
} }
export function closeClawHubDetail(state: SkillsState) { export function closeClawHubDetail(state: SkillsState) {
state.clawhubDetailSlug = null; state.clawhubDetailRef = null;
state.clawhubDetail = null; state.clawhubDetail = null;
state.clawhubDetailError = null; state.clawhubDetailError = null;
state.clawhubDetailLoading = false; state.clawhubDetailLoading = false;
@@ -675,7 +675,7 @@ export function closeClawHubDetail(state: SkillsState) {
export async function installFromClawHub( export async function installFromClawHub(
state: SkillsState, state: SkillsState,
slug: string, ref: string,
acknowledgeClawHubRisk = false, acknowledgeClawHubRisk = false,
version?: string, version?: string,
) { ) {
@@ -684,14 +684,14 @@ export async function installFromClawHub(
return; return;
} }
const agentScope = captureSkillsAgentScope(state); const agentScope = captureSkillsAgentScope(state);
const operation = { kind: "clawhub", slug } as const; const operation = { kind: "clawhub", ref } as const;
state.skillOperation = operation; state.skillOperation = operation;
state.clawhubInstallMessage = null; state.clawhubInstallMessage = null;
try { try {
const result = await client.request<{ message?: string; warning?: string }>("skills.install", { const result = await client.request<{ message?: string; warning?: string }>("skills.install", {
...stateSkillsAgentParams(state), ...stateSkillsAgentParams(state),
source: "clawhub", source: "clawhub",
slug, slug: ref,
...(version ? { version } : {}), ...(version ? { version } : {}),
...(acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}), ...(acknowledgeClawHubRisk ? { acknowledgeClawHubRisk: true } : {}),
}); });
@@ -710,7 +710,7 @@ export async function installFromClawHub(
} }
state.clawhubInstallMessage = { state.clawhubInstallMessage = {
kind: "success", kind: "success",
text: formatClawHubInstallMessage(result?.message ?? `Installed ${slug}`, result?.warning), text: formatClawHubInstallMessage(result?.message ?? `Installed ${ref}`, result?.warning),
}; };
} catch (err) { } catch (err) {
if ( if (
@@ -725,7 +725,7 @@ export async function installFromClawHub(
text: needsAcknowledgement text: needsAcknowledgement
? formatClawHubAcknowledgementMessage(trustDetails?.warning) ? formatClawHubAcknowledgementMessage(trustDetails?.warning)
: formatClawHubInstallMessage(formatUiError(err), trustDetails?.warning), : formatClawHubInstallMessage(formatUiError(err), trustDetails?.warning),
...(needsAcknowledgement ? { acknowledgeSlug: slug } : {}), ...(needsAcknowledgement ? { acknowledgeRef: ref } : {}),
...(needsAcknowledgement && trustDetails?.version ...(needsAcknowledgement && trustDetails?.version
? { acknowledgeVersion: trustDetails.version } ? { acknowledgeVersion: trustDetails.version }
: {}), : {}),
+7 -7
View File
@@ -73,13 +73,13 @@ class SkillsPage extends OpenClawLightDomElement {
@state() skillsDetailTab: SkillDetailTab = "overview"; @state() skillsDetailTab: SkillDetailTab = "overview";
@state() clawhubSearchQuery = ""; @state() clawhubSearchQuery = "";
@state() clawhubDetail: ClawHubSkillDetail | null = null; @state() clawhubDetail: ClawHubSkillDetail | null = null;
@state() clawhubDetailSlug: string | null = null; @state() clawhubDetailRef: string | null = null;
@state() clawhubDetailLoading = false; @state() clawhubDetailLoading = false;
@state() clawhubDetailError: string | null = null; @state() clawhubDetailError: string | null = null;
@state() clawhubInstallMessage: { @state() clawhubInstallMessage: {
kind: "success" | "error"; kind: "success" | "error";
text: string; text: string;
acknowledgeSlug?: string; acknowledgeRef?: string;
acknowledgeVersion?: string; acknowledgeVersion?: string;
acknowledgeLabel?: string; acknowledgeLabel?: string;
} | null = null; } | null = null;
@@ -184,7 +184,7 @@ class SkillsPage extends OpenClawLightDomElement {
this.skillsDetailTab = "overview"; this.skillsDetailTab = "overview";
this.debouncedClawHubSearchQuery = ""; this.debouncedClawHubSearchQuery = "";
this.clawhubDetail = null; this.clawhubDetail = null;
this.clawhubDetailSlug = null; this.clawhubDetailRef = null;
this.clawhubDetailLoading = false; this.clawhubDetailLoading = false;
this.clawhubDetailError = null; this.clawhubDetailError = null;
this.clawhubInstallMessage = null; this.clawhubInstallMessage = null;
@@ -406,7 +406,7 @@ class SkillsPage extends OpenClawLightDomElement {
clawhubSearchLoading: this.clawhubSearchLoading, clawhubSearchLoading: this.clawhubSearchLoading,
clawhubSearchError: this.clawhubSearchError, clawhubSearchError: this.clawhubSearchError,
clawhubDetail: this.clawhubDetail, clawhubDetail: this.clawhubDetail,
clawhubDetailSlug: this.clawhubDetailSlug, clawhubDetailRef: this.clawhubDetailRef,
clawhubDetailLoading: this.clawhubDetailLoading, clawhubDetailLoading: this.clawhubDetailLoading,
clawhubDetailError: this.clawhubDetailError, clawhubDetailError: this.clawhubDetailError,
clawhubInstallMessage: this.clawhubInstallMessage, clawhubInstallMessage: this.clawhubInstallMessage,
@@ -441,11 +441,11 @@ class SkillsPage extends OpenClawLightDomElement {
onDetailClose: () => (this.skillsDetailKey = null), onDetailClose: () => (this.skillsDetailKey = null),
onDetailTabChange: (tab) => this.changeDetailTab(tab), onDetailTabChange: (tab) => this.changeDetailTab(tab),
onClawHubQueryChange: (query) => this.changeClawHubQuery(query), onClawHubQueryChange: (query) => this.changeClawHubQuery(query),
onClawHubDetailOpen: (slug) => void loadClawHubDetail(this, slug), onClawHubDetailOpen: (ref) => void loadClawHubDetail(this, ref),
onClawHubDetailClose: () => closeClawHubDetail(this), onClawHubDetailClose: () => closeClawHubDetail(this),
onClawHubInstall: (slug, acknowledgeClawHubRisk, version) => { onClawHubInstall: (ref, acknowledgeClawHubRisk, version) => {
if (this.canInstallSkills()) { if (this.canInstallSkills()) {
void installFromClawHub(this, slug, acknowledgeClawHubRisk, version); void installFromClawHub(this, ref, acknowledgeClawHubRisk, version);
} }
}, },
})} })}
+461
View File
@@ -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<HTMLButtonElement>(
".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<HTMLElement>(".plugins-item");
const detailButton = container.querySelector<HTMLButtonElement>(".plugins-item__detail-button");
const installButton = container.querySelector<HTMLButtonElement>(".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<HTMLImageElement>(".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<HTMLImageElement>(".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<HTMLButtonElement>(
".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<HTMLElement>(".clawhub-skill-result__button")].map(
(button) => button.closest<HTMLElement>(".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<HTMLButtonElement>(".plugins-item__detail-button")!.click();
row
.querySelector<HTMLButtonElement>(".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<HTMLButtonElement>(".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<HTMLAnchorElement>('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);
});
});
+137
View File
@@ -0,0 +1,137 @@
import type { AgentsListResult, SkillStatusEntry, SkillStatusReport } from "../../api/types.ts";
import type { renderSkills } from "./view.ts";
type SkillsProps = Parameters<typeof renderSkills>[0];
export function normalizeText(node: Element | DocumentFragment): string {
return node.textContent?.replace(/\s+/g, " ").trim() ?? "";
}
export function createSkill(overrides: Partial<SkillStatusEntry> = {}): 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> = {}): 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<string, unknown>;
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];
});
};
}
+8 -490
View File
@@ -3,122 +3,19 @@
import { expectDefined } from "@openclaw/normalization-core"; import { expectDefined } from "@openclaw/normalization-core";
import { render } from "lit"; import { render } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest"; 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 { i18n } from "../../i18n/index.ts";
import { clawhubVerdictKey } from "../../lib/skills/index.ts";
import { getRenderedModalDialog } from "../../test-helpers/modal-dialog.ts"; import { getRenderedModalDialog } from "../../test-helpers/modal-dialog.ts";
import {
createDialogMethodInstaller,
createProps,
createSkill,
normalizeText,
} from "./view.test-support.ts";
import { renderSkills } from "./view.ts"; import { renderSkills } from "./view.ts";
type SkillsProps = Parameters<typeof renderSkills>[0];
const dialogRestores: Array<() => void> = []; const dialogRestores: Array<() => void> = [];
const installDialogMethod = createDialogMethodInstaller(dialogRestores);
function normalizeText(node: Element | DocumentFragment): string {
return node.textContent?.replace(/\s+/g, " ").trim() ?? "";
}
function createSkill(overrides: Partial<SkillStatusEntry> = {}): 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> = {}): 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,
};
}
describe("renderSkills", () => { describe("renderSkills", () => {
afterEach(async () => { afterEach(async () => {
@@ -678,383 +575,4 @@ describe("renderSkills", () => {
expect(showModal).toHaveBeenCalledTimes(1); expect(showModal).toHaveBeenCalledTimes(1);
expect(dialog.open).toBe(true); 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<HTMLButtonElement>(
".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<HTMLElement>(".plugins-item");
const detailButton = container.querySelector<HTMLButtonElement>(".plugins-item__detail-button");
const installButton = container.querySelector<HTMLButtonElement>(".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<HTMLImageElement>(".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<HTMLImageElement>(".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<HTMLButtonElement>(
".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<HTMLButtonElement>(".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<HTMLAnchorElement>('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<string, unknown>;
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];
});
}
+28 -21
View File
@@ -35,7 +35,7 @@ import {
isSkillAvailable, isSkillAvailable,
renderSkillStatusChips, renderSkillStatusChips,
} from "../../lib/skills-shared.ts"; } 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 { import {
clawhubVerdictKey, clawhubVerdictKey,
type ClawHubSkillSecurityVerdict, type ClawHubSkillSecurityVerdict,
@@ -81,13 +81,13 @@ type SkillsProps = {
clawhubSearchLoading: boolean; clawhubSearchLoading: boolean;
clawhubSearchError: string | null; clawhubSearchError: string | null;
clawhubDetail: ClawHubSkillDetail | null; clawhubDetail: ClawHubSkillDetail | null;
clawhubDetailSlug: string | null; clawhubDetailRef: string | null;
clawhubDetailLoading: boolean; clawhubDetailLoading: boolean;
clawhubDetailError: string | null; clawhubDetailError: string | null;
clawhubInstallMessage: { clawhubInstallMessage: {
kind: "success" | "error"; kind: "success" | "error";
text: string; text: string;
acknowledgeSlug?: string; acknowledgeRef?: string;
acknowledgeVersion?: string; acknowledgeVersion?: string;
acknowledgeLabel?: string; acknowledgeLabel?: string;
} | null; } | null;
@@ -103,9 +103,9 @@ type SkillsProps = {
onDetailClose: () => void; onDetailClose: () => void;
onDetailTabChange: (tab: SkillDetailTab) => void; onDetailTabChange: (tab: SkillDetailTab) => void;
onClawHubQueryChange: (query: string) => void; onClawHubQueryChange: (query: string) => void;
onClawHubDetailOpen: (slug: string) => void; onClawHubDetailOpen: (ref: string) => void;
onClawHubDetailClose: () => 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 }; 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; return props.operation?.kind === "skill" && props.operation.skillKey === skillKey;
} }
function activeClawHubMutation(props: SkillsProps, slug: string): boolean { function activeClawHubMutation(props: SkillsProps, ref: string): boolean {
return props.operation?.kind === "clawhub" && props.operation.slug === slug; return props.operation?.kind === "clawhub" && props.operation.ref === ref;
} }
export function renderSkills(props: SkillsProps) { export function renderSkills(props: SkillsProps) {
@@ -286,7 +286,7 @@ export function renderSkills(props: SkillsProps) {
{ wide: true }, { wide: true },
)} )}
${detailSkill ? renderSkillDetail(detailSkill, props) : nothing} ${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.text}
</div> </div>
${props.clawhubInstallMessage.acknowledgeSlug ${props.clawhubInstallMessage.acknowledgeRef
? html`<button ? html`<button
type="button" type="button"
class="btn btn--sm" class="btn btn--sm"
@@ -429,7 +429,7 @@ function renderClawHubSection(props: SkillsProps) {
?disabled=${skillInstallLocked(props)} ?disabled=${skillInstallLocked(props)}
@click=${() => @click=${() =>
props.onClawHubInstall( props.onClawHubInstall(
props.clawhubInstallMessage?.acknowledgeSlug ?? "", props.clawhubInstallMessage?.acknowledgeRef ?? "",
true, true,
props.clawhubInstallMessage?.acknowledgeVersion, props.clawhubInstallMessage?.acknowledgeVersion,
)} )}
@@ -455,13 +455,16 @@ function renderClawHubResults(props: SkillsProps) {
return html` return html`
${results.map((r) => { ${results.map((r) => {
const iconUrl = safeExternalHref(r.icon ?? undefined); const iconUrl = safeExternalHref(r.icon ?? undefined);
// Same slug can appear once per publisher, so the reference is the only thing that tells
// otherwise identical rows apart — and it is what detail and install send back.
const ref = clawHubSkillRef(r);
return html` return html`
<div class="settings-row plugins-item plugins-item--clickable"> <div class="settings-row plugins-item plugins-item--clickable">
<button <button
type="button" type="button"
class="settings-row__text plugins-item__detail-button clawhub-skill-result__button" class="settings-row__text plugins-item__detail-button clawhub-skill-result__button"
aria-label=${t("skillsPage.openDetails", { name: r.displayName })} aria-label=${t("skillsPage.openDetails", { name: ref })}
@click=${() => props.onClawHubDetailOpen(r.slug)} @click=${() => props.onClawHubDetailOpen(ref)}
> >
${iconUrl ${iconUrl
? html`<img class="clawhub-skill-icon" src=${iconUrl} alt="" loading="lazy" />` ? html`<img class="clawhub-skill-icon" src=${iconUrl} alt="" loading="lazy" />`
@@ -469,7 +472,7 @@ function renderClawHubResults(props: SkillsProps) {
<span class="clawhub-skill-result__copy"> <span class="clawhub-skill-result__copy">
<span class="settings-row__title">${r.displayName}</span> <span class="settings-row__title">${r.displayName}</span>
<span class="settings-row__desc"> <span class="settings-row__desc">
${r.summary ? clampText(r.summary, 120) : r.slug} ${r.summary ? `${clampText(r.summary, 100)} · ${ref}` : ref}
</span> </span>
</span> </span>
</button> </button>
@@ -478,9 +481,9 @@ function renderClawHubResults(props: SkillsProps) {
<button <button
class="btn btn--sm" class="btn btn--sm"
?disabled=${skillInstallLocked(props)} ?disabled=${skillInstallLocked(props)}
@click=${() => props.onClawHubInstall(r.slug)} @click=${() => props.onClawHubInstall(ref)}
> >
${activeClawHubMutation(props, r.slug) ${activeClawHubMutation(props, ref)
? t("skillsPage.installing") ? t("skillsPage.installing")
: t("skillsPage.install")} : t("skillsPage.install")}
</button> </button>
@@ -499,11 +502,15 @@ function renderClawHubDetailDialog(props: SkillsProps) {
return html` return html`
<openclaw-modal-dialog <openclaw-modal-dialog
label=${detail?.skill?.displayName ?? props.clawhubDetailSlug ?? t("skillsPage.notFound")} label=${detail?.skill?.displayName ?? props.clawhubDetailRef ?? t("skillsPage.notFound")}
style="--openclaw-modal-width: min(1040px, calc(100vw - 32px));" style="--openclaw-modal-width: min(1040px, calc(100vw - 32px));"
@modal-cancel=${props.onClawHubDetailClose} @modal-cancel=${props.onClawHubDetailClose}
> >
<div class="md-preview-dialog__panel"> <div
class="md-preview-dialog__panel ${props.clawhubDetailError && !props.clawhubDetailLoading
? "md-preview-dialog__panel--message-only"
: ""}"
>
<div class="md-preview-dialog__header"> <div class="md-preview-dialog__header">
<div class="clawhub-skill-detail__identity"> <div class="clawhub-skill-detail__identity">
${detailImageUrl ${detailImageUrl
@@ -516,7 +523,7 @@ function renderClawHubDetailDialog(props: SkillsProps) {
/>` />`
: nothing} : nothing}
<div class="md-preview-dialog__title"> <div class="md-preview-dialog__title">
${detail?.skill?.displayName ?? props.clawhubDetailSlug} ${detail?.skill?.displayName ?? props.clawhubDetailRef}
</div> </div>
</div> </div>
<button class="btn btn--sm" @click=${props.onClawHubDetailClose}> <button class="btn btn--sm" @click=${props.onClawHubDetailClose}>
@@ -562,12 +569,12 @@ function renderClawHubDetailDialog(props: SkillsProps) {
class="btn primary" class="btn primary"
?disabled=${skillInstallLocked(props)} ?disabled=${skillInstallLocked(props)}
@click=${() => { @click=${() => {
if (props.clawhubDetailSlug) { if (props.clawhubDetailRef) {
props.onClawHubInstall(props.clawhubDetailSlug); props.onClawHubInstall(props.clawhubDetailRef);
} }
}} }}
> >
${activeClawHubMutation(props, props.clawhubDetailSlug ?? "") ${activeClawHubMutation(props, props.clawhubDetailRef ?? "")
? t("skillsPage.installing") ? t("skillsPage.installing")
: t("skillsPage.installNamed", { name: detail.skill.displayName })} : t("skillsPage.installNamed", { name: detail.skill.displayName })}
</button> </button>
+6
View File
@@ -3950,6 +3950,12 @@ td.data-table-key-col {
margin var(--duration-normal) var(--ease-out); 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 { .md-preview-dialog__header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;