From 1b0766080a30bd976217bea0b2b87efba439734a Mon Sep 17 00:00:00 2001 From: Gio Della-Libera Date: Sat, 27 Jun 2026 20:56:54 -0700 Subject: [PATCH] Add hosted catalog source profile validation (#95969) Merged via squash. Prepared head SHA: 6dc6ca154c7f0000a6961a347a2d572a2493b8a6 Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com> Co-authored-by: giodl73-repo <235387111+giodl73-repo@users.noreply.github.com> Reviewed-by: @giodl73-repo --- .../official-external-plugin-catalog.test.ts | 597 +++++++++++++++++- .../official-external-plugin-catalog.ts | 422 ++++++++++++- 2 files changed, 991 insertions(+), 28 deletions(-) diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index f1051244f390..88b7967d2223 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -11,6 +11,7 @@ import { createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore, getOfficialExternalPluginCatalogEntry, isOfficialExternalPluginCatalogFeed, + filterOfficialExternalPluginCatalogEntriesBySourceRefs, listOfficialExternalPluginCatalogEntries, loadHostedOfficialExternalPluginCatalogEntries, parseOfficialExternalPluginCatalogEntries, @@ -20,6 +21,7 @@ import { resolveOfficialExternalWebProviderContractPluginIdsForEnv, resolveOfficialExternalPluginId, resolveOfficialExternalPluginInstall, + validateOfficialExternalPluginCatalogEntrySourceRefs, } from "./official-external-plugin-catalog.js"; function expectCatalogEntry(id: string): OfficialExternalPluginCatalogEntry { @@ -30,6 +32,10 @@ function expectCatalogEntry(id: string): OfficialExternalPluginCatalogEntry { return entry; } +function expectRequestUrl(input: RequestInfo | URL): string { + return typeof input === "string" ? input : input instanceof URL ? input.href : input.url; +} + describe("official external plugin catalog", () => { it("keeps hosted fetch guard loading lazy for bundled catalog import paths", () => { const source = readFileSync( @@ -61,6 +67,15 @@ describe("official external plugin catalog", () => { entries: [], }), ).toBe(false); + expect( + isOfficialExternalPluginCatalogFeed({ + schemaVersion: 2, + id: "openclaw-official-external-plugins", + generatedAt: "2026-06-22T00:00:00.000Z", + sequence: 1, + entries: [], + }), + ).toBe(true); expect( isOfficialExternalPluginCatalogFeed({ schemaVersion: 3, @@ -84,7 +99,7 @@ describe("official external plugin catalog", () => { ).toBe(true); }); - it("keeps live ClawHub marketplace entries as metadata-only feed entries", () => { + it("accepts live ClawHub marketplace entries with trusted install candidates", () => { const [entry] = parseOfficialExternalPluginCatalogEntries({ schemaVersion: 2, id: "clawhub-official", @@ -125,8 +140,12 @@ describe("official external plugin catalog", () => { title: "Expedia Travel", version: "1.0.4", }); - expect(resolveOfficialExternalPluginId(entry)).toBeUndefined(); - expect(resolveOfficialExternalPluginInstall(entry)).toBeNull(); + expect(resolveOfficialExternalPluginId(entry)).toBe("@expediagroup/expedia-openclaw"); + expect(resolveOfficialExternalPluginInstall(entry)).toEqual({ + clawhubSpec: "clawhub:@expediagroup/expedia-openclaw@1.0.4", + defaultChoice: "clawhub", + expectedIntegrity: "sha256-s1XdoEQDvsqri7qwaf0eewV4Ji50WeWYzFsZYVtb2rk=", + }); }); it("does not synthesize trusted installs for unavailable or untrusted hosted entries", () => { @@ -170,6 +189,55 @@ describe("official external plugin catalog", () => { ], }, }, + { + type: "plugin", + id: "@example/missing-state", + title: "Missing State", + version: "1.0.0", + publisher: { id: "example", trust: "official" }, + install: { + candidates: [ + { + sourceRef: "public-clawhub", + package: "@example/missing-state", + version: "1.0.0", + }, + ], + }, + }, + { + type: "plugin", + id: "@example/missing-trust", + title: "Missing Trust", + version: "1.0.0", + state: "available", + publisher: { id: "example" }, + install: { + candidates: [ + { + sourceRef: "public-clawhub", + package: "@example/missing-trust", + version: "1.0.0", + }, + ], + }, + }, + { + type: "plugin", + id: "@example/missing-publisher", + title: "Missing Publisher", + version: "1.0.0", + state: "available", + install: { + candidates: [ + { + sourceRef: "public-clawhub", + package: "@example/missing-publisher", + version: "1.0.0", + }, + ], + }, + }, { type: "plugin", id: "@example/private-source", @@ -190,9 +258,9 @@ describe("official external plugin catalog", () => { ], }); - expect(entries).toHaveLength(3); + expect(entries).toHaveLength(6); for (const entry of entries) { - expect(resolveOfficialExternalPluginId(entry)).toBeUndefined(); + expect(resolveOfficialExternalPluginId(entry)).toBe(entry.id); expect(resolveOfficialExternalPluginInstall(entry)).toBeNull(); } }); @@ -318,6 +386,351 @@ describe("official external plugin catalog", () => { }); }); + it("uses the default local feed profile for hosted catalog loading", async () => { + const body = JSON.stringify({ + schemaVersion: 1, + id: "openclaw-official-external-plugins", + generatedAt: "2026-06-22T00:00:00.000Z", + sequence: 8, + entries: [ + { + name: "@openclaw/default-profile-proof", + kind: "plugin", + openclaw: { plugin: { id: "default-profile-proof" } }, + }, + ], + }); + const fetchImpl = vi.fn(async (url: RequestInfo | URL) => { + expect(expectRequestUrl(url)).toBe(DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL); + return new Response(body, { status: 200 }); + }); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + feedProfile: "clawhub-public", + catalogConfig: { + sources: { "acme-npm": { type: "npm", registry: "https://packages.acme.example/npm/" } }, + }, + fetchImpl, + snapshotStore: null, + }); + + expect(result.source).toBe("hosted"); + expect(result.entries.map((entry) => entry.name)).toEqual(["@openclaw/default-profile-proof"]); + }); + + it("accepts the live ClawHub feed source ref by default", async () => { + const body = JSON.stringify({ + schemaVersion: 2, + id: "clawhub-official", + generatedAt: "2026-06-23T09:38:53.000Z", + sequence: 4, + entries: [ + { + type: "plugin", + id: "@openclaw/live-feed-proof", + title: "Live Feed Proof", + version: "1.0.0", + state: "available", + publisher: { id: "openclaw", trust: "official" }, + install: { + candidates: [ + { + sourceRef: "public-clawhub", + package: "@openclaw/live-feed-proof", + version: "1.0.0", + integrity: + "sha256:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9", + }, + ], + }, + }, + ], + }); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + fetchImpl: vi.fn(async (url: RequestInfo | URL) => { + expect(expectRequestUrl(url)).toBe(DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL); + return new Response(body, { status: 200 }); + }), + snapshotStore: null, + }); + + expect(result.source).toBe("hosted"); + expect(result.entries.map((entry) => entry.id)).toEqual(["@openclaw/live-feed-proof"]); + expect(resolveOfficialExternalPluginInstall(result.entries[0])).toEqual({ + clawhubSpec: "clawhub:@openclaw/live-feed-proof@1.0.0", + defaultChoice: "clawhub", + expectedIntegrity: "sha256-s1XdoEQDvsqri7qwaf0eewV4Ji50WeWYzFsZYVtb2rk=", + }); + }); + + it("allows named local feed profiles to authorize their configured HTTPS host", async () => { + const body = JSON.stringify({ + schemaVersion: 1, + id: "openclaw-official-external-plugins", + generatedAt: "2026-06-22T00:00:00.000Z", + sequence: 9, + entries: [ + { + name: "@acme/private-proof", + kind: "plugin", + install: { + candidates: [ + { + sourceRef: "acme-npm", + package: "@acme/private-proof", + version: "1.0.0", + }, + ], + }, + openclaw: { + plugin: { id: "private-proof" }, + install: { sourceRef: "acme-npm", npmSpec: "@acme/private-proof" }, + }, + }, + ], + }); + const fetchImpl = vi.fn(async (url: RequestInfo | URL) => { + expect(expectRequestUrl(url)).toBe("https://packages.acme.example/openclaw/feed"); + return new Response(body, { status: 200 }); + }); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + feedProfile: "acme", + catalogConfig: { + feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } }, + sources: { "acme-npm": { type: "npm", registry: "https://packages.acme.example/npm/" } }, + }, + fetchImpl, + snapshotStore: null, + }); + + expect(result.source).toBe("hosted"); + expect(result.entries.map((entry) => entry.name)).toEqual(["@acme/private-proof"]); + }); + + it("keeps direct hosted feed URL overrides constrained to the public allowlist", async () => { + const fetchImpl = vi.fn(async () => new Response("{}", { status: 200 })); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + feedUrl: "https://packages.acme.example/openclaw/feed", + fetchImpl, + snapshotStore: null, + }); + + expect(result.source).toBe("bundled-fallback"); + expect(fetchImpl).not.toHaveBeenCalled(); + if (result.source === "bundled-fallback") { + expect(result.error).toContain("hostname is not allowed"); + } + }); + + it("requires manifest install source refs when the default feed profile URL is overridden", async () => { + const body = JSON.stringify({ + schemaVersion: 1, + id: "openclaw-official-external-plugins", + generatedAt: "2026-06-22T00:00:00.000Z", + sequence: 13, + entries: [ + { + name: "@acme/default-override-missing-source-ref", + kind: "plugin", + openclaw: { + plugin: { id: "default-override-missing-source-ref" }, + install: { npmSpec: "@acme/default-override-missing-source-ref" }, + }, + }, + { + name: "@acme/default-override-known-source-ref", + kind: "plugin", + openclaw: { + plugin: { id: "default-override-known-source-ref" }, + install: { sourceRef: "acme-npm", npmSpec: "@acme/default-override-known-source-ref" }, + }, + }, + ], + }); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + catalogConfig: { + feeds: { "clawhub-public": { url: "https://packages.acme.example/openclaw/feed" } }, + sources: { "acme-npm": { type: "npm", registry: "https://packages.acme.example/npm/" } }, + }, + fetchImpl: vi.fn(async (url: RequestInfo | URL) => { + expect(expectRequestUrl(url)).toBe("https://packages.acme.example/openclaw/feed"); + return new Response(body, { status: 200 }); + }), + snapshotStore: null, + }); + + expect(result.source).toBe("hosted"); + expect(result.entries.map((entry) => entry.name)).toEqual([ + "@acme/default-override-known-source-ref", + ]); + }); + + it("requires manifest install source refs for custom local feed profiles", async () => { + const missingManifestSourceRef = { + name: "@acme/missing-manifest-source-ref", + kind: "plugin", + openclaw: { + plugin: { id: "missing-manifest-source-ref" }, + install: { npmSpec: "@acme/missing-manifest-source-ref" }, + }, + }; + const implicitNameInstall = { + name: "@acme/implicit-name-install", + kind: "plugin", + openclaw: { plugin: { id: "implicit-name-install" } }, + }; + const topLevelCandidateOnly = { + name: "@acme/top-level-candidate-only", + kind: "plugin", + install: { + candidates: [{ sourceRef: "acme-npm", package: "@acme/top-level-candidate-only" }], + }, + openclaw: { + plugin: { id: "top-level-candidate-only" }, + install: { npmSpec: "@acme/top-level-candidate-only" }, + }, + }; + const knownManifestSourceRef = { + name: "@acme/known-manifest-source-ref", + kind: "plugin", + openclaw: { + plugin: { id: "known-manifest-source-ref" }, + install: { npmSpec: "@acme/known-manifest-source-ref", sourceRef: "acme-npm" }, + }, + }; + const body = JSON.stringify({ + schemaVersion: 1, + id: "openclaw-official-external-plugins", + generatedAt: "2026-06-22T00:00:00.000Z", + sequence: 11, + entries: [ + missingManifestSourceRef, + implicitNameInstall, + topLevelCandidateOnly, + knownManifestSourceRef, + ], + }); + + const catalogConfig = { + feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } }, + sources: { "acme-npm": { type: "npm" as const } }, + }; + + expect( + validateOfficialExternalPluginCatalogEntrySourceRefs(missingManifestSourceRef, { + catalogConfig, + requireManifestInstallSourceRef: true, + }), + ).toEqual(["feed install candidate is missing sourceRef"]); + expect( + validateOfficialExternalPluginCatalogEntrySourceRefs(implicitNameInstall, { + catalogConfig, + requireManifestInstallSourceRef: true, + }), + ).toEqual(["feed install candidate is missing sourceRef"]); + expect( + validateOfficialExternalPluginCatalogEntrySourceRefs(topLevelCandidateOnly, { + catalogConfig, + requireManifestInstallSourceRef: true, + }), + ).toEqual(["feed install candidate is missing sourceRef"]); + expect( + validateOfficialExternalPluginCatalogEntrySourceRefs(knownManifestSourceRef, { + catalogConfig, + requireManifestInstallSourceRef: true, + }), + ).toEqual([]); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + feedProfile: "acme", + catalogConfig, + fetchImpl: vi.fn(async () => new Response(body, { status: 200 })), + snapshotStore: null, + }); + + expect(result.source).toBe("hosted"); + expect(result.entries.map((entry) => entry.name)).toEqual(["@acme/known-manifest-source-ref"]); + }); + + it("filters hosted feed entries that reference unknown local source profiles", async () => { + const knownEntry = { + name: "@openclaw/source-ref-known", + kind: "plugin", + install: { + candidates: [{ sourceRef: "public-clawhub", package: "@openclaw/source-ref-known" }], + }, + openclaw: { plugin: { id: "source-ref-known" } }, + }; + const unknownEntry = { + name: "@openclaw/source-ref-unknown", + kind: "plugin", + install: { + candidates: [{ sourceRef: "attacker-npm", package: "@openclaw/source-ref-unknown" }], + }, + openclaw: { plugin: { id: "source-ref-unknown" } }, + }; + const missingEntry = { + name: "@openclaw/source-ref-missing", + kind: "plugin", + install: { candidates: [{ package: "@openclaw/source-ref-missing" }] }, + openclaw: { plugin: { id: "source-ref-missing" } }, + }; + const manifestInstallWithoutSourceRef = { + name: "@openclaw/source-ref-manifest-missing", + kind: "plugin", + install: { + candidates: [ + { sourceRef: "public-clawhub", package: "@openclaw/source-ref-manifest-missing" }, + ], + }, + openclaw: { + plugin: { id: "source-ref-manifest-missing" }, + install: { npmSpec: "@openclaw/source-ref-manifest-missing" }, + }, + }; + const body = JSON.stringify({ + schemaVersion: 1, + id: "openclaw-official-external-plugins", + generatedAt: "2026-06-22T00:00:00.000Z", + sequence: 10, + entries: [knownEntry, unknownEntry, missingEntry, manifestInstallWithoutSourceRef], + }); + + expect(validateOfficialExternalPluginCatalogEntrySourceRefs(knownEntry)).toEqual([]); + expect(validateOfficialExternalPluginCatalogEntrySourceRefs(unknownEntry)).toEqual([ + 'feed install candidate references unknown sourceRef "attacker-npm"', + ]); + expect(validateOfficialExternalPluginCatalogEntrySourceRefs(missingEntry)).toEqual([ + "feed install candidate is missing sourceRef", + ]); + expect( + validateOfficialExternalPluginCatalogEntrySourceRefs(manifestInstallWithoutSourceRef), + ).toEqual([]); + expect( + filterOfficialExternalPluginCatalogEntriesBySourceRefs([ + knownEntry, + unknownEntry, + missingEntry, + manifestInstallWithoutSourceRef, + ]).map((entry) => entry.name), + ).toEqual(["@openclaw/source-ref-known", "@openclaw/source-ref-manifest-missing"]); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + fetchImpl: vi.fn(async () => new Response(body, { status: 200 })), + snapshotStore: null, + }); + + expect(result.source).toBe("hosted"); + expect(result.entries.map((entry) => entry.name)).toEqual([ + "@openclaw/source-ref-known", + "@openclaw/source-ref-manifest-missing", + ]); + }); + it("falls back to the bundled catalog when hosted feed validation fails", async () => { const result = await loadHostedOfficialExternalPluginCatalogEntries({ snapshotStore: null, @@ -461,7 +874,7 @@ describe("official external plugin catalog", () => { const stateDir = mkdtempSync(path.join(os.tmpdir(), "openclaw-hosted-store-")); try { const store = createSqliteHostedOfficialExternalPluginCatalogSnapshotStore({ stateDir }); - const url = "https://register.openclaw.ai/official-external-plugin-catalog.json"; + const url = "https://clawhub.ai/v1/feeds/plugins"; const firstBody = JSON.stringify({ entries: [] }); const secondBody = JSON.stringify({ entries: [{}] }); @@ -504,6 +917,59 @@ describe("official external plugin catalog", () => { } }); + it("applies custom source-ref validation to exception snapshot fallback", async () => { + const body = JSON.stringify({ + schemaVersion: 1, + id: "openclaw-official-external-plugins", + generatedAt: "2026-06-22T00:00:00.000Z", + sequence: 12, + entries: [ + { + name: "@acme/snapshot-missing-source-ref", + kind: "plugin", + openclaw: { + plugin: { id: "snapshot-missing-source-ref" }, + install: { npmSpec: "@acme/snapshot-missing-source-ref" }, + }, + }, + { + name: "@acme/snapshot-known-source-ref", + kind: "plugin", + openclaw: { + plugin: { id: "snapshot-known-source-ref" }, + install: { sourceRef: "acme-npm", npmSpec: "@acme/snapshot-known-source-ref" }, + }, + }, + ], + }); + const catalogConfig = { + feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } }, + sources: { "acme-npm": { type: "npm" as const } }, + }; + const seeded = await loadHostedOfficialExternalPluginCatalogEntries({ + feedProfile: "acme", + catalogConfig, + snapshotStore: createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore(), + fetchImpl: vi.fn(async () => new Response(body, { status: 200 })), + }); + if (seeded.source !== "hosted") { + throw new Error("expected seeded hosted feed"); + } + const snapshotStore = createInMemoryHostedOfficialExternalPluginCatalogSnapshotStore([ + { body, metadata: seeded.metadata, savedAt: "2026-06-22T01:02:03.000Z" }, + ]); + + const result = await loadHostedOfficialExternalPluginCatalogEntries({ + feedProfile: "acme", + catalogConfig, + snapshotStore, + fetchImpl: vi.fn(async () => new Response("{ nope", { status: 200 })), + }); + + expect(result.source).toBe("hosted-snapshot"); + expect(result.entries.map((entry) => entry.name)).toEqual(["@acme/snapshot-known-source-ref"]); + }); + it("uses the last known good snapshot when the hosted feed returns HTTP 304", async () => { const body = JSON.stringify({ schemaVersion: 1, @@ -797,6 +1263,125 @@ describe("official external plugin catalog", () => { } }); + it("prefers feed install candidates before legacy install metadata", () => { + expect( + resolveOfficialExternalPluginInstall({ + name: "@legacy/plain-package", + kind: "plugin", + state: "available", + publisher: { id: "openclaw", trust: "official" }, + install: { + candidates: [ + { + sourceRef: "public-clawhub", + package: "@openclaw/candidate-package", + version: "1.2.3", + integrity: "sha256:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9", + }, + ], + }, + openclaw: { + plugin: { id: "candidate-package" }, + install: { + npmSpec: "@legacy/plain-package", + minHostVersion: ">=2026.6.1", + expectedIntegrity: "sha256:manifest", + allowInvalidConfigRecovery: true, + }, + }, + }), + ).toEqual({ + clawhubSpec: "clawhub:@openclaw/candidate-package@1.2.3", + defaultChoice: "clawhub", + expectedIntegrity: "sha256-s1XdoEQDvsqri7qwaf0eewV4Ji50WeWYzFsZYVtb2rk=", + minHostVersion: ">=2026.6.1", + allowInvalidConfigRecovery: true, + }); + + expect( + resolveOfficialExternalPluginInstall( + { + name: "@acme/private-package", + kind: "plugin", + state: "available", + publisher: { id: "acme", trust: "official" }, + install: { + candidates: [ + { sourceRef: "acme-npm", package: "@acme/private-package", version: "4.5.6" }, + ], + }, + }, + { catalogConfig: { sources: { "acme-npm": { type: "npm" } } } }, + ), + ).toEqual({ npmSpec: "@acme/private-package@4.5.6", defaultChoice: "npm" }); + + expect( + resolveOfficialExternalPluginInstall( + { + name: "@acme/private-sha-package", + kind: "plugin", + state: "available", + publisher: { id: "acme", trust: "official" }, + install: { + candidates: [ + { + sourceRef: "acme-npm", + package: "@acme/private-sha-package", + version: "4.5.6", + integrity: + "sha256:b355dda04403becaab8bbab069fd1e7b0578262e7459e598cc5b19615b5bdab9", + }, + ], + }, + }, + { catalogConfig: { sources: { "acme-npm": { type: "npm" } } } }, + ), + ).toEqual({ npmSpec: "@acme/private-sha-package@4.5.6", defaultChoice: "npm" }); + + expect( + resolveOfficialExternalPluginInstall( + { + name: "@acme/private-sri-package", + kind: "plugin", + state: "available", + publisher: { id: "acme", trust: "official" }, + install: { + candidates: [ + { + sourceRef: "acme-npm", + package: "@acme/private-sri-package", + version: "4.5.6", + integrity: "sha512-abc=", + }, + ], + }, + }, + { catalogConfig: { sources: { "acme-npm": { type: "npm" } } } }, + ), + ).toEqual({ + npmSpec: "@acme/private-sri-package@4.5.6", + defaultChoice: "npm", + expectedIntegrity: "sha512-abc=", + }); + + expect( + resolveOfficialExternalPluginInstall( + { + name: "git-only-package", + kind: "plugin", + install: { + candidates: [{ sourceRef: "acme-git", package: "git@example.com:acme/plugin.git" }], + }, + }, + { catalogConfig: { sources: { "acme-git": { type: "git" } } } }, + ), + ).toBeNull(); + + expect( + resolveOfficialExternalPluginInstall({ id: "metadata-only", title: "Metadata only" }), + ).toBeNull(); + }); + it("lists the externalized provider and capability plugins with install metadata", () => { const providers = [ ["arcee", "@openclaw/arcee-provider"], diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index fb30fb3745ef..b2af12a19bab 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -6,6 +6,7 @@ import officialExternalChannelCatalog from "../../scripts/lib/official-external- import officialExternalPluginCatalog from "../../scripts/lib/official-external-plugin-catalog.json" with { type: "json" }; import officialExternalProviderCatalog from "../../scripts/lib/official-external-provider-catalog.json" with { type: "json" }; import { MANIFEST_KEY } from "../compat/legacy-names.js"; +import { normalizeClawHubSha256Integrity } from "../infra/clawhub.js"; import { isRecord } from "../utils.js"; import type { PluginManifestChannelConfig, @@ -71,7 +72,7 @@ export type OfficialExternalPluginCatalogManifest = { }; providers?: readonly OfficialExternalProviderCatalogProvider[]; webSearchProviders?: readonly OfficialExternalWebSearchProvider[]; - install?: PluginPackageInstall; + install?: PluginPackageInstall & { sourceRef?: string }; contracts?: PluginManifestContracts; channelConfigs?: Record; }; @@ -101,6 +102,32 @@ export type OfficialExternalPluginCatalogInstallCandidate = { package?: string; version?: string; integrity?: string; + repo?: string; + path?: string; + commit?: string; +}; + +export type OfficialExternalPluginCatalogSourceProfile = + | { + type: "npm"; + registry?: string; + } + | { + type: "clawhub"; + baseUrl?: string; + } + | { + type: "git"; + baseUrl?: string; + }; + +export type OfficialExternalPluginCatalogFeedProfile = { + url: string; +}; + +export type OfficialExternalPluginCatalogProfileConfig = { + feeds?: Record; + sources?: Record; }; /** Feed-shaped wrapper used by the bundled external plugin catalog fallback. */ @@ -171,9 +198,30 @@ const OFFICIAL_CATALOG_SOURCES = [ officialExternalPluginCatalog, ] as const; -const OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS = new Set([1, 2]); +const SUPPORTED_OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS = new Set([1, 2]); export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL = "https://clawhub.ai/v1/feeds/plugins"; +export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE = "clawhub-public"; +export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_SOURCE_REF = "public-clawhub"; +export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_NPM_SOURCE_REF = "public-npm"; +export const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_PROFILE_CONFIG: OfficialExternalPluginCatalogProfileConfig = + { + feeds: { + [DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE]: { + url: DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL, + }, + }, + sources: { + [DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_SOURCE_REF]: { + type: "clawhub", + baseUrl: "https://clawhub.ai", + }, + [DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_NPM_SOURCE_REF]: { + type: "npm", + registry: "https://registry.npmjs.org/", + }, + }, + }; const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_TIMEOUT_MS = 5000; const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_MAX_BYTES = 1024 * 1024; const DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CHUNK_TIMEOUT_MS = 5000; @@ -188,7 +236,8 @@ export function isOfficialExternalPluginCatalogFeed( const sequence = raw.sequence; const entries = raw.entries; return ( - OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS.has(raw.schemaVersion) && + typeof raw.schemaVersion === "number" && + SUPPORTED_OFFICIAL_EXTERNAL_CATALOG_FEED_SCHEMA_VERSIONS.has(raw.schemaVersion) && typeof raw.id === "string" && raw.id.trim().length > 0 && typeof raw.generatedAt === "string" && @@ -233,23 +282,184 @@ function sha256Hex(value: string): string { return `sha256:${createHash("sha256").update(value).digest("hex")}`; } -function resolveHostedCatalogFeedUrl(feedUrl: string | undefined): URL { - const raw = feedUrl?.trim() || DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL; +function resolveHostedCatalogFeedUrl(raw: string): URL { let parsed: URL; try { - parsed = new URL(raw); + parsed = new URL(raw.trim()); } catch { throw new Error("hosted catalog feed URL is invalid"); } if (parsed.protocol !== "https:") { throw new Error("hosted catalog feed URL must use HTTPS"); } - if (!OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST.includes(parsed.hostname)) { - throw new Error("hosted catalog feed URL hostname is not allowed"); - } return parsed; } +function resolveOfficialExternalPluginCatalogProfileConfig( + config?: OfficialExternalPluginCatalogProfileConfig, +): Required { + return { + feeds: { + ...DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_PROFILE_CONFIG.feeds, + ...config?.feeds, + }, + sources: { + ...DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_PROFILE_CONFIG.sources, + ...config?.sources, + }, + }; +} + +function resolveHostedCatalogFeedSource(params: { + feedUrl?: string; + feedProfile?: string; + catalogConfig?: OfficialExternalPluginCatalogProfileConfig; +}): { url: URL; hostnameAllowlist: string[] } { + const profileConfig = resolveOfficialExternalPluginCatalogProfileConfig(params.catalogConfig); + const explicitFeedUrl = normalizeOptionalString(params.feedUrl); + if (explicitFeedUrl) { + const url = resolveHostedCatalogFeedUrl(explicitFeedUrl); + if (!OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST.includes(url.hostname)) { + throw new Error("hosted catalog feed URL hostname is not allowed"); + } + return { url, hostnameAllowlist: OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST }; + } + const profileName = + normalizeOptionalString(params.feedProfile) ?? + DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE; + const profile = profileConfig.feeds[profileName]; + if (!profile) { + throw new Error(`hosted catalog feed profile "${profileName}" is not configured`); + } + const url = resolveHostedCatalogFeedUrl(profile.url); + return { + url, + hostnameAllowlist: uniqueStrings([ + ...OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST, + url.hostname, + ]), + }; +} + +function getOfficialExternalPluginCatalogSourceRefs( + config?: OfficialExternalPluginCatalogProfileConfig, +): Set { + return new Set(Object.keys(resolveOfficialExternalPluginCatalogProfileConfig(config).sources)); +} + +function getFeedEntryInstallCandidateRecords( + entry: OfficialExternalPluginCatalogEntry, +): OfficialExternalPluginCatalogInstallCandidate[] { + const install = isRecord(entry.install) ? entry.install : undefined; + const candidates = install?.candidates; + if (!Array.isArray(candidates)) { + return []; + } + return candidates.filter( + (candidate): candidate is OfficialExternalPluginCatalogInstallCandidate => isRecord(candidate), + ); +} + +function getFeedEntryInstallCandidates( + entry: OfficialExternalPluginCatalogEntry, +): OfficialExternalPluginCatalogInstallCandidate[] { + const state = normalizeOptionalString(entry.state); + if (state !== "available") { + return []; + } + const publisherTrust = normalizeOptionalString(entry.publisher?.trust); + if (publisherTrust !== "official") { + return []; + } + return getFeedEntryInstallCandidateRecords(entry); +} + +function shouldRequireManifestInstallSourceRef(params: { + feedProfile?: string; + catalogConfig?: OfficialExternalPluginCatalogProfileConfig; +}): boolean { + const profileName = + normalizeOptionalString(params.feedProfile) ?? + DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE; + if (profileName !== DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE) { + return true; + } + const profileConfig = resolveOfficialExternalPluginCatalogProfileConfig(params.catalogConfig); + const profileUrl = normalizeOptionalString(profileConfig.feeds[profileName]?.url); + try { + return ( + resolveHostedCatalogFeedUrl(profileUrl ?? DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL) + .href !== + resolveHostedCatalogFeedUrl(DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_URL).href + ); + } catch { + return true; + } +} + +function getManifestInstallSourceRefCandidate( + entry: OfficialExternalPluginCatalogEntry, +): OfficialExternalPluginCatalogInstallCandidate | undefined { + const install = getOfficialExternalPluginCatalogManifest(entry)?.install; + if (!install) { + return undefined; + } + const hasInstallSpec = Boolean( + normalizeOptionalString(install.clawhubSpec) || + normalizeOptionalString(install.npmSpec) || + normalizeOptionalString(install.localPath), + ); + if (!hasInstallSpec) { + return undefined; + } + return { + sourceRef: normalizeOptionalString(install.sourceRef), + package: + normalizeOptionalString(install.npmSpec) ?? normalizeOptionalString(install.clawhubSpec), + }; +} + +export function validateOfficialExternalPluginCatalogEntrySourceRefs( + entry: OfficialExternalPluginCatalogEntry, + params?: { + catalogConfig?: OfficialExternalPluginCatalogProfileConfig; + requireManifestInstallSourceRef?: boolean; + }, +): string[] { + const configuredSourceRefs = getOfficialExternalPluginCatalogSourceRefs(params?.catalogConfig); + const errors: string[] = []; + let candidates = getFeedEntryInstallCandidateRecords(entry); + if (params?.requireManifestInstallSourceRef) { + const manifestCandidate = getManifestInstallSourceRefCandidate(entry); + if (manifestCandidate) { + candidates = [...candidates, manifestCandidate]; + } else if (candidates.length === 0) { + candidates = [{}]; + } + } + for (const candidate of candidates) { + const sourceRef = normalizeOptionalString(candidate.sourceRef); + if (!sourceRef) { + errors.push("feed install candidate is missing sourceRef"); + } else if (!configuredSourceRefs.has(sourceRef)) { + errors.push(`feed install candidate references unknown sourceRef "${sourceRef}"`); + } + } + return errors; +} + +export function filterOfficialExternalPluginCatalogEntriesBySourceRefs( + entries: OfficialExternalPluginCatalogEntry[], + params?: { + catalogConfig?: OfficialExternalPluginCatalogProfileConfig; + requireManifestInstallSourceRef?: boolean; + }, +): OfficialExternalPluginCatalogEntry[] { + return entries.filter( + (entry) => validateOfficialExternalPluginCatalogEntrySourceRefs(entry, params).length === 0, + ); +} + function parseHostedCatalogContentLength(raw: string | null, maxBytes: number): void { const normalized = normalizeOptionalString(raw); if (!normalized) { @@ -353,7 +563,9 @@ async function readHostedCatalogResponseText(params: { function bundledOfficialExternalPluginCatalogEntries(): OfficialExternalPluginCatalogEntry[] { return OFFICIAL_CATALOG_SOURCES.flatMap((source) => - parseOfficialExternalPluginCatalogEntries(source), + filterOfficialExternalPluginCatalogEntriesBySourceRefs( + parseOfficialExternalPluginCatalogEntries(source), + ), ); } @@ -410,6 +622,8 @@ function loadHostedCatalogSnapshotResult(params: { expectedSha256?: string; ifNoneMatch?: string; ifModifiedSince?: string; + catalogConfig?: OfficialExternalPluginCatalogProfileConfig; + requireManifestInstallSourceRef?: boolean; }): HostedOfficialExternalPluginCatalogLoadResult { assertSnapshotMatchesRequestValidators({ snapshot: params.snapshot, @@ -425,12 +639,18 @@ function loadHostedCatalogSnapshotResult(params: { } const raw = JSON.parse(params.snapshot.body) as unknown; if (!isOfficialExternalPluginCatalogFeed(raw)) { - throw new Error("hosted catalog snapshot did not match schema version 1"); + throw new Error("hosted catalog snapshot did not match a supported schema version"); } return { source: "hosted-snapshot", entries: dedupeOfficialExternalPluginCatalogEntries( - parseOfficialExternalPluginCatalogEntries(raw), + filterOfficialExternalPluginCatalogEntriesBySourceRefs( + parseOfficialExternalPluginCatalogEntries(raw), + { + catalogConfig: params.catalogConfig, + requireManifestInstallSourceRef: params.requireManifestInstallSourceRef, + }, + ), ), feed: raw, metadata: params.snapshot.metadata, @@ -464,6 +684,8 @@ async function snapshotOrBundledFallbackResult(params: { expectedSha256?: string; ifNoneMatch?: string; ifModifiedSince?: string; + catalogConfig?: OfficialExternalPluginCatalogProfileConfig; + requireManifestInstallSourceRef?: boolean; }): Promise { if (params.snapshotStore) { try { @@ -475,6 +697,8 @@ async function snapshotOrBundledFallbackResult(params: { expectedSha256: params.expectedSha256, ifNoneMatch: params.ifNoneMatch, ifModifiedSince: params.ifModifiedSince, + catalogConfig: params.catalogConfig, + requireManifestInstallSourceRef: params.requireManifestInstallSourceRef, }); } } catch (snapshotErr) { @@ -524,6 +748,8 @@ async function resolveHostedCatalogSnapshotStore(params: { export async function loadHostedOfficialExternalPluginCatalogEntries(params?: { feedUrl?: string; + feedProfile?: string; + catalogConfig?: OfficialExternalPluginCatalogProfileConfig; fetchImpl?: FetchLike; timeoutMs?: number; maxBytes?: number; @@ -537,12 +763,17 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: { stateDatabasePath?: string; now?: () => Date; }): Promise { - let url: URL; + let source: { url: URL; hostnameAllowlist: string[] }; try { - url = resolveHostedCatalogFeedUrl(params?.feedUrl); + source = resolveHostedCatalogFeedSource({ + feedUrl: params?.feedUrl, + feedProfile: params?.feedProfile, + catalogConfig: params?.catalogConfig, + }); } catch (err) { return bundledFallbackResult(err); } + const { url } = source; const snapshotStore = await resolveHostedCatalogSnapshotStore({ snapshotStore: params?.snapshotStore, env: params?.env, @@ -580,7 +811,7 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: { requireHttps: true, maxRedirects: 2, timeoutMs: params?.timeoutMs ?? DEFAULT_HOSTED_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_TIMEOUT_MS, - policy: { hostnameAllowlist: OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_HOSTNAME_ALLOWLIST }, + policy: { hostnameAllowlist: source.hostnameAllowlist }, auditContext: "official-external-plugin-catalog-feed", }); response = guarded.response; @@ -595,6 +826,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: { expectedSha256, ifNoneMatch, ifModifiedSince, + catalogConfig: params?.catalogConfig, + requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({ + feedProfile: params?.feedProfile, + catalogConfig: params?.catalogConfig, + }), }); } if (!response.ok) { @@ -606,6 +842,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: { expectedSha256, ifNoneMatch, ifModifiedSince, + catalogConfig: params?.catalogConfig, + requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({ + feedProfile: params?.feedProfile, + catalogConfig: params?.catalogConfig, + }), }); } const body = await readHostedCatalogResponseText({ @@ -625,6 +866,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: { expectedSha256, ifNoneMatch, ifModifiedSince, + catalogConfig: params?.catalogConfig, + requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({ + feedProfile: params?.feedProfile, + catalogConfig: params?.catalogConfig, + }), }); } const raw = JSON.parse(body) as unknown; @@ -637,8 +883,23 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: { expectedSha256, ifNoneMatch, ifModifiedSince, + catalogConfig: params?.catalogConfig, + requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({ + feedProfile: params?.feedProfile, + catalogConfig: params?.catalogConfig, + }), }); } + const entries = filterOfficialExternalPluginCatalogEntriesBySourceRefs( + parseOfficialExternalPluginCatalogEntries(raw), + { + catalogConfig: params?.catalogConfig, + requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({ + feedProfile: params?.feedProfile, + catalogConfig: params?.catalogConfig, + }), + }, + ); await snapshotStore ?.write({ body, @@ -648,9 +909,7 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: { .catch(() => undefined); return { source: "hosted", - entries: dedupeOfficialExternalPluginCatalogEntries( - parseOfficialExternalPluginCatalogEntries(raw), - ), + entries: dedupeOfficialExternalPluginCatalogEntries(entries), feed: raw, metadata, }; @@ -662,6 +921,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: { expectedSha256, ifNoneMatch, ifModifiedSince, + catalogConfig: params?.catalogConfig, + requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({ + feedProfile: params?.feedProfile, + catalogConfig: params?.catalogConfig, + }), }); } finally { if (response?.bodyUsed !== true) { @@ -675,6 +939,100 @@ function normalizeDefaultChoice(value: unknown): PluginPackageInstall["defaultCh return value === "clawhub" || value === "npm" || value === "local" ? value : undefined; } +function formatFeedInstallCandidateSpec( + candidate: OfficialExternalPluginCatalogInstallCandidate, +): string | undefined { + const packageName = normalizeOptionalString(candidate.package); + if (!packageName) { + return undefined; + } + const version = normalizeOptionalString(candidate.version); + if (!version || packageName.endsWith(`@${version}`)) { + return packageName; + } + return `${packageName}@${version}`; +} + +function getFeedEntryCandidateSourceType( + candidate: OfficialExternalPluginCatalogInstallCandidate, + config?: OfficialExternalPluginCatalogProfileConfig, +): OfficialExternalPluginCatalogSourceProfile["type"] | undefined { + const sourceRef = normalizeOptionalString(candidate.sourceRef); + if (!sourceRef) { + return undefined; + } + return resolveOfficialExternalPluginCatalogProfileConfig(config).sources[sourceRef]?.type; +} + +function getPreferredFeedEntryInstallCandidate(params: { + entry: OfficialExternalPluginCatalogEntry; + catalogConfig?: OfficialExternalPluginCatalogProfileConfig; +}): OfficialExternalPluginCatalogInstallCandidate | undefined { + const candidates = getFeedEntryInstallCandidates(params.entry).filter((candidate) => + Boolean(normalizeOptionalString(candidate.package)), + ); + return ( + candidates.find( + (candidate) => + normalizeOptionalString(candidate.sourceRef) === + DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_SOURCE_REF, + ) ?? + candidates.find( + (candidate) => + normalizeOptionalString(candidate.sourceRef) === + DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_NPM_SOURCE_REF, + ) ?? + candidates.find((candidate) => + Boolean(getFeedEntryCandidateSourceType(candidate, params.catalogConfig)), + ) + ); +} + +function resolveFeedEntryInstallCandidate(params: { + entry: OfficialExternalPluginCatalogEntry; + catalogConfig?: OfficialExternalPluginCatalogProfileConfig; +}): PluginPackageInstall | null { + const candidate = getPreferredFeedEntryInstallCandidate(params); + if (!candidate) { + return null; + } + const spec = formatFeedInstallCandidateSpec(candidate); + if (!spec) { + return null; + } + const sourceType = getFeedEntryCandidateSourceType(candidate, params.catalogConfig); + if (sourceType === "clawhub") { + const expectedIntegrity = normalizeClawHubSha256ExpectedIntegrity(candidate.integrity); + return { + clawhubSpec: `clawhub:${spec}`, + defaultChoice: "clawhub", + ...(expectedIntegrity ? { expectedIntegrity } : {}), + }; + } + if (sourceType === "npm") { + const expectedIntegrity = normalizeNpmExpectedIntegrity(candidate.integrity); + return { + npmSpec: spec, + defaultChoice: "npm", + ...(expectedIntegrity ? { expectedIntegrity } : {}), + }; + } + return null; +} + +function normalizeClawHubSha256ExpectedIntegrity(value: unknown): string | undefined { + const integrity = normalizeOptionalString(value); + return integrity ? (normalizeClawHubSha256Integrity(integrity) ?? undefined) : undefined; +} + +function normalizeNpmExpectedIntegrity(value: unknown): string | undefined { + const integrity = normalizeOptionalString(value); + if (!integrity || !/^[a-z0-9]+-[A-Za-z0-9+/=]+$/i.test(integrity)) { + return undefined; + } + return integrity; +} + /** Returns manifest metadata from an official external catalog entry when present. */ export function getOfficialExternalPluginCatalogManifest( entry: OfficialExternalPluginCatalogEntry, @@ -690,7 +1048,8 @@ export function resolveOfficialExternalPluginId( return ( normalizeOptionalString(manifest?.plugin?.id) ?? normalizeOptionalString(manifest?.channel?.id) ?? - normalizeOptionalString(manifest?.providers?.[0]?.id) + normalizeOptionalString(manifest?.providers?.[0]?.id) ?? + normalizeOptionalString(entry.id) ); } @@ -719,6 +1078,7 @@ export function resolveOfficialExternalPluginLabel( normalizeOptionalString(manifest?.plugin?.label) ?? normalizeOptionalString(manifest?.channel?.label) ?? normalizeOptionalString(manifest?.providers?.[0]?.name) ?? + normalizeOptionalString(entry.title) ?? normalizeOptionalString(entry.name) ?? resolveOfficialExternalPluginId(entry) ?? "plugin" @@ -727,18 +1087,36 @@ export function resolveOfficialExternalPluginLabel( export function resolveOfficialExternalPluginInstall( entry: OfficialExternalPluginCatalogEntry, + params?: { catalogConfig?: OfficialExternalPluginCatalogProfileConfig }, ): PluginPackageInstall | null { const manifest = getOfficialExternalPluginCatalogManifest(entry); const install = manifest?.install; const clawhubSpec = normalizeOptionalString(install?.clawhubSpec); - const npmSpec = normalizeOptionalString(install?.npmSpec) ?? normalizeOptionalString(entry.name); + const manifestNpmSpec = normalizeOptionalString(install?.npmSpec); const localPath = normalizeOptionalString(install?.localPath); - if (!clawhubSpec && !npmSpec && !localPath) { - return null; + const candidateInstall = resolveFeedEntryInstallCandidate({ + entry, + catalogConfig: params?.catalogConfig, + }); + if (candidateInstall) { + return { + ...candidateInstall, + ...(install?.minHostVersion ? { minHostVersion: install.minHostVersion } : {}), + ...(install?.expectedIntegrity && !candidateInstall.expectedIntegrity + ? { expectedIntegrity: install.expectedIntegrity } + : {}), + ...(install?.allowInvalidConfigRecovery === true ? { allowInvalidConfigRecovery: true } : {}), + }; } + const hasFeedInstallCandidates = getFeedEntryInstallCandidateRecords(entry).length > 0; + const npmSpec = + manifestNpmSpec ?? (hasFeedInstallCandidates ? undefined : normalizeOptionalString(entry.name)); const defaultChoice = normalizeDefaultChoice(install?.defaultChoice) ?? (npmSpec ? "npm" : clawhubSpec ? "clawhub" : localPath ? "local" : undefined); + if (!clawhubSpec && !npmSpec && !localPath) { + return null; + } return { ...(clawhubSpec ? { clawhubSpec } : {}), ...(npmSpec ? { npmSpec } : {}),