Add hosted catalog config profiles

This commit is contained in:
Gio Della-Libera
2026-06-22 22:30:13 -07:00
parent c3348ecc54
commit d1f39278c0
9 changed files with 319 additions and 26 deletions

View File

@@ -6,6 +6,18 @@ export const FIELD_HELP: Record<string, string> = {
meta: "Metadata fields automatically maintained by OpenClaw to record write/version history for this config file. Keep these values system-managed and avoid manual edits unless debugging migration history.",
"meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.",
"meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).",
marketplaces:
"Marketplace feed and local package source profile settings. Feeds provide package selection and governance metadata, while sources define the local source names that install candidates may reference.",
"marketplaces.feeds":
"Named marketplace feed profiles. The default public profile can be used as shipped, and deployments can add or override profiles to point OpenClaw at their effective feed endpoint.",
"marketplaces.feeds.*.url":
"HTTPS URL for the marketplace feed profile. Remote feed documents cannot introduce new registry domains or credentials; they only reference locally configured sources by name.",
"marketplaces.feeds.*.verification":
"Feed authenticity policy. This slice accepts only unsigned HTTPS feeds; signed verification is added when envelope enforcement is wired.",
"marketplaces.sources":
"Named package source profiles that feed entries can reference using sourceRef. Keep credentials and registry endpoints local so remote feeds cannot bootstrap trust roots.",
"marketplaces.sources.*.type":
"Package source profile type: npm, clawhub, or git. This slice validates sourceRef names only; registry and host endpoints are added when installer resolution can enforce them.",
env: "Environment import and override settings used to supply runtime variables to the gateway process. Use this section to control shell-env loading and explicit variable injection behavior.",
"env.shellEnv":
"Shell environment import controls for loading variables from your login shell during startup. Keep this enabled when you depend on profile-defined secrets or PATH customizations.",

View File

@@ -5,6 +5,12 @@ export const FIELD_LABELS: Record<string, string> = {
meta: "Metadata",
"meta.lastTouchedVersion": "Config Last Touched Version",
"meta.lastTouchedAt": "Config Last Touched At",
marketplaces: "Marketplaces",
"marketplaces.feeds": "Marketplace Feeds",
"marketplaces.feeds.*.url": "Marketplace Feed URL",
"marketplaces.feeds.*.verification": "Marketplace Feed Verification",
"marketplaces.sources": "Marketplace Sources",
"marketplaces.sources.*.type": "Marketplace Source Type",
env: "Environment",
"env.shellEnv": "Shell Environment Import",
"env.shellEnv.enabled": "Shell Environment Import Enabled",

View File

@@ -0,0 +1,25 @@
// Defines marketplace feed and package source profile configuration types.
export type MarketplaceFeedVerificationConfig = {
mode: "unsigned";
};
export type MarketplaceFeedProfileConfig = {
url: string;
verification?: MarketplaceFeedVerificationConfig;
};
export type MarketplaceSourceProfileConfig =
| {
type: "npm";
}
| {
type: "clawhub";
}
| {
type: "git";
};
export type MarketplacesConfig = {
feeds?: Record<string, MarketplaceFeedProfileConfig>;
sources?: Record<string, MarketplaceSourceProfileConfig>;
};

View File

@@ -15,6 +15,7 @@ import type { CrestodianConfig } from "./types.crestodian.js";
import type { CronConfig } from "./types.cron.js";
import type { DiscoveryConfig, GatewayConfig, TalkConfig } from "./types.gateway.js";
import type { HooksConfig } from "./types.hooks.js";
import type { MarketplacesConfig } from "./types.marketplaces.js";
import type { McpConfig } from "./types.mcp.js";
import type { MemoryConfig } from "./types.memory.js";
import type {
@@ -176,6 +177,8 @@ export type OpenClawConfig = {
};
/** Secret providers, defaults, and ref-resolution settings. */
secrets?: SecretsConfig;
/** Marketplace feed and local package source profile configuration. */
marketplaces?: MarketplacesConfig;
/** Skill loading and bundled skill configuration. */
skills?: SkillsConfig;
/** Plugin registry/install/runtime configuration. */

View File

@@ -8,6 +8,7 @@ export * from "./types.approvals.js";
export * from "./types.auth.js";
export * from "./types.base.js";
export * from "./types.browser.js";
export * from "./types.marketplaces.js";
export * from "./types.channels.js";
export * from "./types.cli.js";
export * from "./types.commitments.js";

View File

@@ -0,0 +1,129 @@
// Verifies marketplace feed and source profile config parsing.
import { describe, expect, it } from "vitest";
import { OpenClawSchema } from "./zod-schema.js";
function expectMarketplacesConfig(value: unknown) {
const result = OpenClawSchema.safeParse(value);
if (!result.success) {
throw new Error(JSON.stringify(result.error.issues, null, 2));
}
return result.data.marketplaces;
}
describe("OpenClawSchema marketplaces config", () => {
it("accepts hosted feed and local source profiles", () => {
const marketplaces = expectMarketplacesConfig({
marketplaces: {
feeds: {
"clawhub-public": {
url: "https://clawhub.ai/v1/feeds/plugins",
verification: { mode: "unsigned" },
},
acme: {
url: "https://packages.acme.example/openclaw/feed",
verification: { mode: "unsigned" },
},
},
sources: {
"public-clawhub": { type: "clawhub" },
"public-npm": { type: "npm" },
"acme-npm": { type: "npm" },
"acme-clawhub": { type: "clawhub" },
"acme-git": { type: "git" },
},
},
});
expect(marketplaces?.feeds?.acme.url).toBe("https://packages.acme.example/openclaw/feed");
expect(marketplaces?.sources?.["acme-git"].type).toBe("git");
});
it.each([
"http://packages.acme.example/openclaw/feed",
"https://token@packages.acme.example/openclaw/feed",
"https://user:pass@packages.acme.example/openclaw/feed",
"not a url",
])("rejects invalid or credential-bearing hosted feed URL %s without throwing", (url) => {
expect(() =>
OpenClawSchema.safeParse({
marketplaces: {
feeds: { acme: { url } },
},
}),
).not.toThrow();
const result = OpenClawSchema.safeParse({
marketplaces: {
feeds: { acme: { url } },
},
});
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
"marketplaces.feeds.acme.url",
);
}
});
it("rejects refresh, auth, and signed verification until loader enforcement exists", () => {
expect(
OpenClawSchema.safeParse({
marketplaces: {
feeds: {
acme: {
url: "https://packages.acme.example/openclaw/feed",
auth: { scheme: "bearer", secret: "token" },
},
},
},
}).success,
).toBe(false);
expect(
OpenClawSchema.safeParse({
marketplaces: {
feeds: {
acme: {
url: "https://packages.acme.example/openclaw/feed",
refresh: { onStartup: "if-stale" },
},
},
},
}).success,
).toBe(false);
expect(
OpenClawSchema.safeParse({
marketplaces: {
feeds: {
acme: {
url: "https://packages.acme.example/openclaw/feed",
verification: { mode: "signed" },
},
},
},
}).success,
).toBe(false);
});
it("rejects unknown source profile types", () => {
const result = OpenClawSchema.safeParse({
marketplaces: {
sources: { acme: { type: "container" } },
},
});
expect(result.success).toBe(false);
});
it("rejects source endpoints until installer resolution can enforce them", () => {
const result = OpenClawSchema.safeParse({
marketplaces: {
sources: {
"acme-npm": { type: "npm", registry: "https://packages.acme.example/npm/" },
"acme-clawhub": { type: "clawhub", baseUrl: "https://packages.acme.example/clawhub/" },
},
},
});
expect(result.success).toBe(false);
});
});

View File

@@ -484,6 +484,45 @@ const CrestodianSchema = z
.strict()
.optional();
function isHttpsUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === "https:" && !url.username && !url.password;
} catch {
return false;
}
}
const MarketplaceVerificationSchema = z
.object({
mode: z.literal("unsigned"),
})
.strict();
const MarketplaceFeedProfileSchema = z
.object({
url: z
.string()
.url()
.refine((value) => isHttpsUrl(value), "Expected https:// URL"),
verification: MarketplaceVerificationSchema.optional(),
})
.strict();
const MarketplaceSourceProfileSchema = z.union([
z.object({ type: z.literal("npm") }).strict(),
z.object({ type: z.literal("clawhub") }).strict(),
z.object({ type: z.literal("git") }).strict(),
]);
const MarketplacesSchema = z
.object({
feeds: z.record(z.string().min(1), MarketplaceFeedProfileSchema).optional(),
sources: z.record(z.string().min(1), MarketplaceSourceProfileSchema).optional(),
})
.strict()
.optional();
const CommitmentsSchema = z
.object({
enabled: z.boolean().optional(),
@@ -739,6 +778,7 @@ export const OpenClawSchema = z
.strict()
.optional(),
secrets: SecretsConfigSchema,
marketplaces: MarketplacesSchema,
auth: z
.object({
profiles: z

View File

@@ -13,6 +13,7 @@ import {
isOfficialExternalPluginCatalogFeed,
filterOfficialExternalPluginCatalogEntriesBySourceRefs,
listOfficialExternalPluginCatalogEntries,
loadConfiguredHostedOfficialExternalPluginCatalogEntries,
loadHostedOfficialExternalPluginCatalogEntries,
parseOfficialExternalPluginCatalogEntries,
resolveOfficialExternalProviderContractPluginIds,
@@ -21,6 +22,7 @@ import {
resolveOfficialExternalWebProviderContractPluginIdsForEnv,
resolveOfficialExternalPluginId,
resolveOfficialExternalPluginInstall,
resolveOfficialExternalPluginCatalogProfileConfigFromConfig,
validateOfficialExternalPluginCatalogEntrySourceRefs,
} from "./official-external-plugin-catalog.js";
@@ -220,6 +222,47 @@ describe("official external plugin catalog", () => {
});
});
it("loads hosted catalog profiles from OpenClaw config", async () => {
const config = {
marketplaces: {
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
sources: { "acme-npm": { type: "npm" as const } },
},
};
const body = JSON.stringify({
schemaVersion: 1,
id: "openclaw-official-external-plugins",
generatedAt: "2026-06-22T00:00:00.000Z",
sequence: 14,
entries: [
{
name: "@acme/config-profile-proof",
kind: "plugin",
openclaw: {
plugin: { id: "config-profile-proof" },
install: { sourceRef: "acme-npm", npmSpec: "@acme/config-profile-proof" },
},
},
],
});
expect(resolveOfficialExternalPluginCatalogProfileConfigFromConfig(config)).toBe(
config.marketplaces,
);
const result = await loadConfiguredHostedOfficialExternalPluginCatalogEntries(config, {
feedProfile: "acme",
fetchImpl: vi.fn(async (url: RequestInfo | URL) => {
expect(String(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/config-profile-proof"]);
});
it("allows named local feed profiles to authorize their configured HTTPS host", async () => {
const body = JSON.stringify({
schemaVersion: 1,

View File

@@ -15,6 +15,16 @@ import type {
type ManifestKey = typeof MANIFEST_KEY;
class HostedCatalogSnapshotWriteError extends Error {
readonly originalError: unknown;
constructor(originalError: unknown) {
super("hosted catalog snapshot write failed");
this.name = "HostedCatalogSnapshotWriteError";
this.originalError = originalError;
}
}
export type OfficialExternalProviderAuthChoice = {
method?: string;
choiceId?: string;
@@ -695,6 +705,8 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
ifNoneMatch?: string;
ifModifiedSince?: string;
expectedSha256?: string;
offline?: boolean;
requireSnapshotWrite?: boolean;
snapshotStore?: HostedOfficialExternalPluginCatalogSnapshotStore | null;
env?: NodeJS.ProcessEnv;
stateDir?: string;
@@ -718,10 +730,24 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
stateDir: params?.stateDir,
stateDatabasePath: params?.stateDatabasePath,
});
const expectedSha256 = normalizeOptionalString(params?.expectedSha256);
const requireManifestInstallSourceRef = shouldRequireManifestInstallSourceRef({
feedProfile: params?.feedProfile,
catalogConfig: params?.catalogConfig,
});
if (params?.offline === true) {
return await snapshotOrBundledFallbackResult({
error: "hosted catalog feed offline mode",
snapshotStore,
url: url.href,
expectedSha256,
catalogConfig: params?.catalogConfig,
requireManifestInstallSourceRef,
});
}
const headers = new Headers();
const ifNoneMatch = normalizeOptionalString(params?.ifNoneMatch);
const ifModifiedSince = normalizeOptionalString(params?.ifModifiedSince);
const expectedSha256 = normalizeOptionalString(params?.expectedSha256);
if (ifNoneMatch) {
headers.set("if-none-match", ifNoneMatch);
}
@@ -763,10 +789,7 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
metadata: base,
expectedSha256,
catalogConfig: params?.catalogConfig,
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
feedProfile: params?.feedProfile,
catalogConfig: params?.catalogConfig,
}),
requireManifestInstallSourceRef,
});
}
if (!response.ok) {
@@ -777,10 +800,7 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
metadata: base,
expectedSha256,
catalogConfig: params?.catalogConfig,
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
feedProfile: params?.feedProfile,
catalogConfig: params?.catalogConfig,
}),
requireManifestInstallSourceRef,
});
}
const body = await readHostedCatalogResponseText({
@@ -799,10 +819,7 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
metadata,
expectedSha256,
catalogConfig: params?.catalogConfig,
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
feedProfile: params?.feedProfile,
catalogConfig: params?.catalogConfig,
}),
requireManifestInstallSourceRef,
});
}
const raw = JSON.parse(body) as unknown;
@@ -814,20 +831,14 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
metadata,
expectedSha256,
catalogConfig: params?.catalogConfig,
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
feedProfile: params?.feedProfile,
catalogConfig: params?.catalogConfig,
}),
requireManifestInstallSourceRef,
});
}
const entries = filterOfficialExternalPluginCatalogEntriesBySourceRefs(
parseOfficialExternalPluginCatalogEntries(raw),
{
catalogConfig: params?.catalogConfig,
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
feedProfile: params?.feedProfile,
catalogConfig: params?.catalogConfig,
}),
requireManifestInstallSourceRef,
},
);
await snapshotStore
@@ -836,7 +847,11 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
metadata,
savedAt: (params?.now?.() ?? new Date()).toISOString(),
})
.catch(() => undefined);
.catch((err: unknown) => {
if (params?.requireSnapshotWrite) {
throw new HostedCatalogSnapshotWriteError(err);
}
});
return {
source: "hosted",
entries: dedupeOfficialExternalPluginCatalogEntries(entries),
@@ -844,16 +859,16 @@ export async function loadHostedOfficialExternalPluginCatalogEntries(params?: {
metadata,
};
} catch (err) {
if (err instanceof HostedCatalogSnapshotWriteError) {
throw err.originalError;
}
return await snapshotOrBundledFallbackResult({
error: err,
snapshotStore,
url: url.href,
expectedSha256,
catalogConfig: params?.catalogConfig,
requireManifestInstallSourceRef: shouldRequireManifestInstallSourceRef({
feedProfile: params?.feedProfile,
catalogConfig: params?.catalogConfig,
}),
requireManifestInstallSourceRef,
});
} finally {
if (response?.bodyUsed !== true) {
@@ -1043,6 +1058,25 @@ export function resolveOfficialExternalPluginInstall(
};
}
export function resolveOfficialExternalPluginCatalogProfileConfigFromConfig(config?: {
marketplaces?: OfficialExternalPluginCatalogProfileConfig;
}): OfficialExternalPluginCatalogProfileConfig | undefined {
return config?.marketplaces;
}
export async function loadConfiguredHostedOfficialExternalPluginCatalogEntries(
config: { marketplaces?: OfficialExternalPluginCatalogProfileConfig } | undefined,
params?: Omit<
Parameters<typeof loadHostedOfficialExternalPluginCatalogEntries>[0],
"catalogConfig"
>,
): Promise<HostedOfficialExternalPluginCatalogLoadResult> {
return await loadHostedOfficialExternalPluginCatalogEntries({
...params,
catalogConfig: resolveOfficialExternalPluginCatalogProfileConfigFromConfig(config),
});
}
export function listOfficialExternalPluginCatalogEntries(): OfficialExternalPluginCatalogEntry[] {
return dedupeOfficialExternalPluginCatalogEntries(bundledOfficialExternalPluginCatalogEntries());
}