mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-26 12:26:38 -06:00
f61ec66249
* fix(skills): keep ClawHub search results on the source the operator picked ClawHub search returns each result's origin under `install.reference`, but the response model expected a flat `installRef`. That field is never present, so every row fell through to a synthesized `@owner/slug` reference. External skills.sh results were rewritten onto a ClawHub-native identity, dropping both the commit-pinned source and the "not scanned by ClawHub" trust record. Map the search wire shape explicitly and make the search contract action-specific: `installRef` always names the result's own source, `detailRef` appears only while ClawHub can serve a detail card for that identity, and `trustState` travels with unscanned sources. Clients render install directly when detail is absent instead of offering a review the Gateway must refuse. Covers the Control UI, macOS, iOS Settings, iOS AgentPro, and Android, which previously routed every row through review and could not install an external skill at all. * fix(skills): make install-only sources explicit and keep legacy review intact Address review findings on the search identity contract: - Replace the detail-reference capability with an explicit `installOnly` flag. A Gateway released before this field omits it, and reading omission as install-only made ordinary registry results skip the reviewed-version flow on every client. Absence now means the existing review-then-install path. - Parse closed source variants in the producer. A row whose source is unknown, whose external reference is missing, or whose registry publisher is absent is dropped instead of falling through to `@owner/slug`, which was the original source swap in a different disguise. - Carry the exact install reference alongside the canonical slug. The Gateway already records `requestedReference`; the clients dropped it and matched installs by slug, so a completed external install read back as unknown. - Gate the direct-install action on admin rights. The row previously stayed enabled for read-only operators and reached a guard that silently returned. - Route the unscanned-source warning through the native and Control UI string catalogs instead of a hardcoded literal. * chore(i18n): leave generated native locale artifacts to the refresh workflow Preflight isolates generated locale output from source changes: only the native sources and apps/.i18n/native-source.json belong in a feature commit. * fix(skills): satisfy Android ktlint wrapping and Swift test link construction Extract the ClawHub result action guard into a named value so the multiline condition follows ktlint wrapping, and pass the new requestedReference field in the OpenClawKit installed-link fixtures. * fix(skills): preserve external install identity across clients * test(skills): add exact refs to recommendation fixtures --------- Co-authored-by: Patrick Erichsen <patrick.a.erichsen@gmail.com>
202 lines
9.7 KiB
Swift
202 lines
9.7 KiB
Swift
import Foundation
|
|
import OpenClawProtocol
|
|
import Testing
|
|
@testable import OpenClawKit
|
|
|
|
struct SkillManagementTests {
|
|
@Test func `detail review uses exact detail version and publisher`() throws {
|
|
let data = Data(
|
|
#"{"skill":{"displayName":"Weather","summary":"Forecasts"},"latestVersion":{"version":"2.0.0"},"owner":{"handle":"molly","displayName":"Molly"}}"#
|
|
.utf8)
|
|
let fallbackData = Data(
|
|
#"{"slug":"weather","displayName":"Old Weather","summary":null,"version":"1.0.0"}"#.utf8)
|
|
let detail = try JSONDecoder().decode(ClawHubSkillDetail.self, from: data)
|
|
let fallback = try JSONDecoder().decode(ClawHubSkillSummary.self, from: fallbackData)
|
|
let review = try #require(ClawHubSkillInstallReview(
|
|
detail: detail,
|
|
fallback: fallback))
|
|
|
|
#expect(review.slug == "@molly/weather")
|
|
#expect(review.displayName == "Weather")
|
|
#expect(review.version == "2.0.0")
|
|
#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 `install-only results keep their source and expose no detail action`() throws {
|
|
let data = Data(
|
|
#"{"results":[{"slug":"pdf","installRef":"skills-sh:openai/skills/pdf","installOnly":true,"trustState":"not-scanned-by-clawhub","displayName":"Pdf"},{"slug":"pdf","installRef":"@awspace/pdf","displayName":"Pdf"}]}"#
|
|
.utf8)
|
|
let search = try JSONDecoder().decode(ClawHubSkillSearchResult.self, from: data)
|
|
let external = try #require(search.results.first)
|
|
let native = try #require(search.results.last)
|
|
|
|
// Rewriting the external reference to @openai/pdf would install a different skill.
|
|
#expect(external.reference == "skills-sh:openai/skills/pdf")
|
|
#expect(!external.canReadDetails)
|
|
#expect(external.isUnscannedSource)
|
|
#expect(native.canReadDetails)
|
|
|
|
let review = ClawHubSkillInstallReview(directInstall: external)
|
|
#expect(review.slug == "skills-sh:openai/skills/pdf")
|
|
// The Gateway pins external sources to a commit and rejects a version selector.
|
|
#expect(review.version == nil)
|
|
#expect(review.requestedReference == "skills-sh:openai/skills/pdf")
|
|
}
|
|
|
|
@Test func `results without the install-only flag keep the review flow`() throws {
|
|
// A Gateway released before the flag existed omits it from every row.
|
|
let data = Data(#"{"results":[{"slug":"email","installRef":"@alice/email","displayName":"Email"}]}"#.utf8)
|
|
let search = try JSONDecoder().decode(ClawHubSkillSearchResult.self, from: data)
|
|
let legacy = try #require(search.results.first)
|
|
|
|
// Treating omission as install-only would bypass the reviewed-version step those
|
|
// Gateways still expect.
|
|
#expect(legacy.canReadDetails)
|
|
#expect(!legacy.isUnscannedSource)
|
|
#expect(legacy.reference == "@alice/email")
|
|
}
|
|
|
|
@Test func `install-only readback matches the recorded reference`() throws {
|
|
let data = Data(
|
|
#"{"name":"pdf","description":"","source":"clawhub","filePath":"/s/pdf/SKILL.md","baseDir":"/s/pdf","skillKey":"pdf","primaryEnv":null,"emoji":null,"homepage":null,"always":false,"disabled":false,"eligible":true,"requirements":{"bins":[],"env":[],"config":[]},"missing":{"bins":[],"env":[],"config":[]},"configChecks":[],"install":[],"clawhub":{"status":"linked","valid":true,"slug":"pdf","requestedReference":"skills-sh:openai/skills/pdf","installedVersion":"0.0.0"}}"#
|
|
.utf8)
|
|
let installed = [try JSONDecoder().decode(SkillStatus.self, from: data)]
|
|
|
|
// The canonical slug is "pdf", so matching by the sent reference is the only readback that
|
|
// identifies this install without colliding with a registry skill of the same slug.
|
|
#expect(SkillManagementContract.installed(
|
|
installed,
|
|
requestedReference: "skills-sh:openai/skills/pdf"))
|
|
let external = try JSONDecoder().decode(
|
|
ClawHubSkillSummary.self,
|
|
from: Data(
|
|
#"{"slug":"pdf","installRef":"skills-sh:openai/skills/pdf","installOnly":true,"displayName":"Pdf"}"#.utf8))
|
|
#expect(SkillManagementContract.installed(installed, searchResult: external))
|
|
#expect(!SkillManagementContract.installed(
|
|
installed,
|
|
requestedReference: "skills-sh:someone-else/skills/pdf"))
|
|
}
|
|
|
|
@Test func `risk acknowledgement stays bound to reviewed version`() {
|
|
let matching = GatewayResponseError(
|
|
method: "skills.install",
|
|
code: "UNAVAILABLE",
|
|
message: "Review warning",
|
|
details: [
|
|
"clawhubTrustCode": AnyCodable("clawhub_risk_acknowledgement_required"),
|
|
"version": AnyCodable("2.0.0"),
|
|
"warning": AnyCodable("Automated analysis found risky behavior."),
|
|
])
|
|
let accepted = SkillManagementContract.rejection(from: matching, attemptedVersion: "2.0.0")
|
|
#expect(accepted.requiresAcknowledgement)
|
|
#expect(accepted.acknowledgeVersion == "2.0.0")
|
|
|
|
let stale = SkillManagementContract.rejection(from: matching, attemptedVersion: "1.0.0")
|
|
#expect(!stale.requiresAcknowledgement)
|
|
#expect(stale.acknowledgeVersion == nil)
|
|
#expect(stale.message.contains("different ClawHub release"))
|
|
}
|
|
|
|
@Test func `missing requirements preserve alternatives and platforms`() throws {
|
|
let data = Data(#"{"bins":[],"anyBins":["rg","grep"],"env":[],"config":[],"os":["darwin"]}"#.utf8)
|
|
let missing = try JSONDecoder().decode(SkillMissing.self, from: data)
|
|
|
|
#expect(missing.anyBins == ["rg", "grep"])
|
|
#expect(missing.os == ["darwin"])
|
|
}
|
|
|
|
@Test func `legacy requirements default new fields to empty`() throws {
|
|
let data = Data(#"{"bins":["rg"],"env":[],"config":[]}"#.utf8)
|
|
let requirements = try JSONDecoder().decode(SkillRequirements.self, from: data)
|
|
let missing = try JSONDecoder().decode(SkillMissing.self, from: data)
|
|
|
|
#expect(requirements.anyBins.isEmpty)
|
|
#expect(requirements.os.isEmpty)
|
|
#expect(missing.anyBins.isEmpty)
|
|
#expect(missing.os.isEmpty)
|
|
}
|
|
|
|
@Test func `qualified install remains busy for unqualified browse row`() {
|
|
#expect(SkillManagementContract.sameClawHubSkill("@molly/weather", "weather"))
|
|
#expect(!SkillManagementContract.sameClawHubSkill("@molly/weather", "@alice/weather"))
|
|
}
|
|
|
|
@Test func `installed readback requires valid provenance and exact version`() {
|
|
let linked = Self.skill(
|
|
clawhub: ClawHubInstalledSkillLink(
|
|
status: "linked",
|
|
valid: true,
|
|
slug: "@molly/weather",
|
|
ownerHandle: "molly",
|
|
requestedReference: nil,
|
|
installedVersion: "2.0.0",
|
|
reason: nil))
|
|
#expect(SkillManagementContract.installed([linked], slug: "weather", version: "2.0.0"))
|
|
#expect(!SkillManagementContract.installed([linked], slug: "weather", version: "2.0.1"))
|
|
#expect(SkillManagementContract.installed([linked], slug: "weather"))
|
|
}
|
|
|
|
@Test func `owner qualified readback matches split provenance identity`() {
|
|
let linked = Self.skill(
|
|
clawhub: ClawHubInstalledSkillLink(
|
|
status: "linked",
|
|
valid: true,
|
|
slug: "weather",
|
|
ownerHandle: "molly",
|
|
requestedReference: nil,
|
|
installedVersion: "2.0.0",
|
|
reason: nil))
|
|
#expect(SkillManagementContract.installed([linked], slug: "@molly/weather", version: "2.0.0"))
|
|
#expect(!SkillManagementContract.installed([linked], slug: "@other/weather", version: "2.0.0"))
|
|
}
|
|
|
|
@Test func `agent filtered skills need setup instead of reporting ready`() {
|
|
let blocked = Self.skill(clawhub: nil, blockedByAgentFilter: true)
|
|
#expect(!SkillManagementContract.ready(blocked))
|
|
#expect(SkillManagementContract.needsSetup(blocked))
|
|
}
|
|
|
|
@Test func `platform incompatible skills need setup instead of reporting ready`() {
|
|
let blocked = Self.skill(clawhub: nil, platformIncompatible: true)
|
|
#expect(!SkillManagementContract.ready(blocked))
|
|
#expect(SkillManagementContract.needsSetup(blocked))
|
|
}
|
|
|
|
private static func skill(
|
|
clawhub: ClawHubInstalledSkillLink?,
|
|
blockedByAgentFilter: Bool? = nil,
|
|
platformIncompatible: Bool? = nil) -> SkillStatus
|
|
{
|
|
SkillStatus(
|
|
name: "Weather",
|
|
description: "Forecasts",
|
|
source: "openclaw-managed",
|
|
filePath: "/tmp/weather/SKILL.md",
|
|
baseDir: "/tmp/weather",
|
|
skillKey: "weather",
|
|
primaryEnv: nil,
|
|
emoji: "☀️",
|
|
homepage: nil,
|
|
always: false,
|
|
disabled: false,
|
|
blockedByAgentFilter: blockedByAgentFilter,
|
|
platformIncompatible: platformIncompatible,
|
|
eligible: true,
|
|
requirements: SkillRequirements(bins: [], env: [], config: []),
|
|
missing: SkillMissing(bins: [], env: [], config: []),
|
|
configChecks: [],
|
|
install: [],
|
|
clawhub: clawhub)
|
|
}
|
|
}
|