Files
openclaw/apps/macos/Sources/OpenClaw/ClawHubSkillsBrowser.swift
T
Vyctor H. Brzezowski aba94bbe0b 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>
2026-08-12 20:13:15 -07:00

406 lines
15 KiB
Swift

import Observation
import OpenClawKit
import SwiftUI
private enum ClawHubReviewSheet: Identifiable {
case install(ClawHubSkillInstallReview, route: GatewayConnection.Route)
case risk(ClawHubSkillInstallReview, route: GatewayConnection.Route, message: String, warning: String?)
var id: String {
switch self {
case let .install(review, _): "install:\(review.id)"
case let .risk(review, _, _, _): "risk:\(review.id)"
}
}
}
struct ClawHubSkillsBrowser: View {
@State private var model = ClawHubSkillsBrowserModel()
let installedSkills: [SkillStatus]
let onInstalled: ([SkillStatus]) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 14) {
SettingsCardGroup("Browse ClawHub") {
SettingsCardRow(
title: "Discover skills",
subtitle: "The Gateway verifies the exact reviewed release before download.",
showsDivider: false)
{
TextField("Search ClawHub", text: self.$model.query)
.textFieldStyle(.roundedBorder)
.frame(width: 260)
.onSubmit { Task { await self.model.search() } }
Button {
Task { await self.model.search() }
} label: {
Label("Search", systemImage: "magnifyingglass")
}
.buttonStyle(.borderedProminent)
.disabled(self.model.isSearching)
}
}
if let notice = self.model.notice {
ClawHubNoticeCard(notice: notice)
}
SettingsCardGroup("Results") {
if self.model.isSearching, self.model.results.isEmpty {
SettingsCardRow(title: "Searching ClawHub…", showsDivider: false) {
ProgressView().controlSize(.small)
}
} else if self.model.results.isEmpty {
SettingsCardRow(
title: "No skills found",
subtitle: "Try another search or refresh the catalog.",
showsDivider: false)
{
EmptyView()
}
} else {
LazyVStack(spacing: 0) {
ForEach(Array(self.model.results.enumerated()), id: \.element.id) { index, skill in
ClawHubSkillResultRow(
skill: skill,
installed: skill.version.map {
SkillManagementContract.installed(
self.installedSkills,
slug: skill.reference,
version: $0)
} ?? SkillManagementContract.installed(
self.installedSkills,
slug: skill.reference),
isBusy: self.model.reviewingSlug == skill.reference || self.model.installingSlug.map {
SkillManagementContract.sameClawHubSkill($0, skill.reference)
} == true,
showsDivider: index != self.model.results.count - 1)
{
Task { await self.model.review(skill) }
}
}
}
}
}
}
.task { await self.model.searchIfNeeded() }
.sheet(item: self.$model.sheet) { sheet in
switch sheet {
case let .install(review, route):
ClawHubInstallReviewSheet(
review: review,
isInstalling: self.model.installingSlug == review.slug,
onCancel: { self.model.sheet = nil },
onInstall: {
Task {
if let skills = await self.model.install(review, route: route, acknowledgeRisk: false) {
self.onInstalled(skills)
}
}
})
case let .risk(review, route, message, warning):
ClawHubRiskReviewSheet(
review: review,
message: message,
warning: warning,
isInstalling: self.model.installingSlug == review.slug,
onCancel: { self.model.sheet = nil },
onInstall: {
Task {
if let skills = await self.model.install(review, route: route, acknowledgeRisk: true) {
self.onInstalled(skills)
}
}
})
}
}
}
}
private struct ClawHubSkillResultRow: View {
let skill: ClawHubSkillSummary
let installed: Bool
let isBusy: Bool
let showsDivider: Bool
let onReview: () -> Void
/// Same-slug rows share a display name and often a summary, so the reference always shows:
/// it is the only thing that tells them apart and what review and install send back.
private var subtitle: String {
guard let summary = self.skill.summary else { return self.skill.reference }
return "\(summary) · \(self.skill.reference)"
}
var body: some View {
SettingsCardRow(
title: .verbatim(self.skill.displayName),
subtitle: .verbatim(self.subtitle),
showsDivider: self.showsDivider)
{
if let version = self.skill.version {
Text(version)
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
Button(self.installed ? "Installed" : "Review", action: self.onReview)
.buttonStyle(.bordered)
.disabled(self.isBusy || self.installed)
}
}
}
private struct ClawHubInstallReviewSheet: View {
let review: ClawHubSkillInstallReview
let isInstalling: Bool
let onCancel: () -> Void
let onInstall: () -> Void
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Text("Review ClawHub skill").font(.title2.bold())
ClawHubReviewDetails(review: self.review)
Text("The Gateway will verify this exact release with ClawHub before download.")
.font(.footnote)
.foregroundStyle(.secondary)
HStack {
Spacer()
Button("Cancel", action: self.onCancel)
Button("Verify and install", action: self.onInstall)
.buttonStyle(.borderedProminent)
.disabled(self.isInstalling)
}
}
.padding(24)
.frame(width: 480)
}
}
private struct ClawHubRiskReviewSheet: View {
let review: ClawHubSkillInstallReview
let message: String
let warning: String?
let isInstalling: Bool
let onCancel: () -> Void
let onInstall: () -> Void
@State private var warningExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 18) {
Label("Gateway warning", systemImage: "exclamationmark.triangle.fill")
.font(.title2.bold())
.foregroundStyle(.orange)
ClawHubReviewDetails(review: self.review)
Text(self.message).font(.body)
DisclosureGroup("Review warning details", isExpanded: self.$warningExpanded) {
Text(self.warning ?? "The Gateway requires explicit acknowledgement for this release.")
.font(.callout)
.textSelection(.enabled)
.padding(.top, 8)
}
Text("Expand and review the Gateway warning before acknowledging this exact version.")
.font(.footnote)
.foregroundStyle(.secondary)
HStack {
Spacer()
Button("Cancel", action: self.onCancel)
Button("Acknowledge and install", action: self.onInstall)
.buttonStyle(.borderedProminent)
.disabled(!self.warningExpanded || self.isInstalling)
}
}
.padding(24)
.frame(width: 520)
}
}
private struct ClawHubReviewDetails: View {
let review: ClawHubSkillInstallReview
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(self.review.displayName).font(.headline)
if let summary = self.review.summary {
Text(summary).foregroundStyle(.secondary)
}
LabeledContent("Version", value: self.review.version)
LabeledContent("Publisher", value: self.review.author)
}
}
}
private struct ClawHubNoticeCard: View {
let notice: ClawHubSkillsBrowserModel.Notice
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: self.notice.isError ? "exclamationmark.triangle.fill" : "checkmark.circle.fill")
.foregroundStyle(self.notice.isError ? .orange : .green)
VStack(alignment: .leading, spacing: 4) {
Text(self.notice.title).font(.headline)
Text(self.notice.message).font(.footnote).textSelection(.enabled)
if let warning = self.notice.warning {
Text(warning).font(.footnote).foregroundStyle(.secondary).textSelection(.enabled)
}
}
Spacer()
}
.padding(14)
.background(.quaternary.opacity(0.45), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
}
@MainActor
@Observable
private final class ClawHubSkillsBrowserModel {
struct Notice {
let title: String
let message: String
let warning: String?
let isError: Bool
}
var query = ""
var results: [ClawHubSkillSummary] = []
var isSearching = false
var reviewingSlug: String?
var installingSlug: String?
var sheet: ClawHubReviewSheet?
var notice: Notice?
private var hasSearched = false
func searchIfNeeded() async {
guard !self.hasSearched else { return }
await self.search()
}
func search() async {
guard !self.isSearching else { return }
self.isSearching = true
self.notice = nil
defer { self.isSearching = false }
do {
guard let route = await GatewayConnection.shared.captureRoute() else {
throw ClawHubSkillsBrowserError.gatewayUnavailable
}
self.results = try await GatewayConnection.shared.skillsSearch(query: self.query, on: route)
self.hasSearched = true
} catch {
self.notice = Notice(
title: "ClawHub unavailable",
message: error.localizedDescription,
warning: nil,
isError: true)
}
}
func review(_ skill: ClawHubSkillSummary) async {
guard self.reviewingSlug == nil else { return }
self.reviewingSlug = skill.reference
self.notice = nil
defer { self.reviewingSlug = nil }
do {
guard let route = await GatewayConnection.shared.captureRoute() else {
throw ClawHubSkillsBrowserError.gatewayUnavailable
}
let detail = try await GatewayConnection.shared.skillsDetail(slug: skill.reference, on: route)
guard let review = ClawHubSkillInstallReview(detail: detail, fallback: skill) else {
throw ClawHubSkillsBrowserError.missingInstallVersion
}
self.sheet = .install(review, route: route)
} catch {
self.notice = Notice(
title: "Could not review skill",
message: error.localizedDescription,
warning: nil,
isError: true)
}
}
func install(
_ review: ClawHubSkillInstallReview,
route: GatewayConnection.Route,
acknowledgeRisk: Bool) async -> [SkillStatus]?
{
guard self.installingSlug == nil else { return nil }
self.installingSlug = review.slug
self.notice = nil
defer { self.installingSlug = nil }
do {
let result = try await GatewayConnection.shared.skillsInstallClawHub(
slug: review.slug,
version: review.version,
acknowledgeRisk: acknowledgeRisk,
on: route)
let report = try await GatewayConnection.shared.skillsStatus(on: route)
guard SkillManagementContract.installed(report.skills, slug: review.slug, version: review.version) else {
self.sheet = nil
self.notice = Notice(
title: "Install result unknown",
// swiftlint:disable line_length
message: "Reconnect, refresh Skills, then retry. The Gateway safely joins a matching install still running.",
// swiftlint:enable line_length
warning: result.warning,
isError: true)
return nil
}
self.sheet = nil
self.notice = Notice(
title: "Installed",
message: result.message,
warning: result.warning,
isError: false)
return report.skills
} catch let error as GatewayResponseError {
let rejection = SkillManagementContract.rejection(from: error, attemptedVersion: review.version)
if rejection.requiresAcknowledgement, !acknowledgeRisk {
self.sheet = .risk(
review,
route: route,
message: rejection.message,
warning: rejection.warning)
} else {
self.sheet = nil
self.notice = Notice(
title: "Gateway blocked install",
message: rejection.message,
warning: rejection.warning,
isError: true)
}
return nil
} catch {
if let report = try? await GatewayConnection.shared.skillsStatus(on: route),
SkillManagementContract.installed(report.skills, slug: review.slug, version: review.version)
{
self.sheet = nil
self.notice = Notice(
title: "Installed",
message: "The Gateway installed the reviewed version.",
warning: nil,
isError: false)
return report.skills
}
self.sheet = nil
self.notice = Notice(
title: "Install result unknown",
message: error.localizedDescription,
warning: nil,
isError: true)
return nil
}
}
}
private enum ClawHubSkillsBrowserError: LocalizedError {
case gatewayUnavailable
case missingInstallVersion
var errorDescription: String? {
switch self {
case .gatewayUnavailable:
"Connect to a Gateway and try again."
case .missingInstallVersion:
"ClawHub did not report an installable version for this skill."
}
}
}