Skip to content

Commit ef4d81c

Browse files
committed
Add hosted catalog config profiles
1 parent 9241b97 commit ef4d81c

11 files changed

Lines changed: 330 additions & 32 deletions
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
f5a5855ddd7aa8c23a732f257eceaa20fd163b1d5f342c909f4aef15aa8643cf config-baseline.json
2-
b8dffdb1a328aaf728a0707ab04d21c65f1a225a2360042e10832aa608699716 config-baseline.core.json
1+
26afb40d889df462d5886e6fa0ecd09ce6bae12387836b59a2489c696a8ff3a1 config-baseline.json
2+
00242f93938579d3ff3130f8d42e08a423e43dc7a73e3b5e5afb45b80c78d25a config-baseline.core.json
33
671979e86e4c4f59415d0a20879e838f9bbd883b3d29eeb02cb5131db8d187fe config-baseline.channel.json
4-
94529978588d6e3776a86780b22cf9ff46a6f9957f2f178d3829403fad451ca7 config-baseline.plugin.json
4+
21ce3d97ac3a83323fa29ddc35045c04f6eae3040c2c5515077f543b4608fd12 config-baseline.plugin.json

scripts/plugin-sdk-surface-report.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
116116
"approval-reply-runtime": 1,
117117
"config-runtime": 123,
118118
"config-contracts": 1,
119-
"config-types": 416,
119+
"config-types": 420,
120120
"config-schema": 3,
121121
"reply-dedupe": 1,
122122
"inbound-reply-dispatch": 33,
@@ -202,11 +202,11 @@ let publicDeprecatedExportsByEntrypointBudget;
202202
try {
203203
budgets = {
204204
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 322),
205-
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10388),
205+
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10392),
206206
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5214),
207207
publicDeprecatedExports: readBudgetEnv(
208208
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
209-
3249,
209+
3253,
210210
),
211211
publicWildcardReexports: readBudgetEnv(
212212
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_WILDCARD_REEXPORTS",

src/config/schema.help.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,18 @@ export const FIELD_HELP: Record<string, string> = {
66
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.",
77
"meta.lastTouchedVersion": "Auto-set when OpenClaw writes the config.",
88
"meta.lastTouchedAt": "ISO timestamp of the last config write (auto-set).",
9+
marketplaces:
10+
"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.",
11+
"marketplaces.feeds":
12+
"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.",
13+
"marketplaces.feeds.*.url":
14+
"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.",
15+
"marketplaces.feeds.*.verification":
16+
"Feed authenticity policy. This slice accepts only unsigned HTTPS feeds; signed verification is added when envelope enforcement is wired.",
17+
"marketplaces.sources":
18+
"Named package source profiles that feed entries can reference using sourceRef. Keep credentials and registry endpoints local so remote feeds cannot bootstrap trust roots.",
19+
"marketplaces.sources.*.type":
20+
"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.",
921
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.",
1022
"env.shellEnv":
1123
"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.",

src/config/schema.labels.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ export const FIELD_LABELS: Record<string, string> = {
55
meta: "Metadata",
66
"meta.lastTouchedVersion": "Config Last Touched Version",
77
"meta.lastTouchedAt": "Config Last Touched At",
8+
marketplaces: "Marketplaces",
9+
"marketplaces.feeds": "Marketplace Feeds",
10+
"marketplaces.feeds.*.url": "Marketplace Feed URL",
11+
"marketplaces.feeds.*.verification": "Marketplace Feed Verification",
12+
"marketplaces.sources": "Marketplace Sources",
13+
"marketplaces.sources.*.type": "Marketplace Source Type",
814
env: "Environment",
915
"env.shellEnv": "Shell Environment Import",
1016
"env.shellEnv.enabled": "Shell Environment Import Enabled",

src/config/types.marketplaces.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Defines marketplace feed and package source profile configuration types.
2+
export type MarketplaceFeedVerificationConfig = {
3+
mode: "unsigned";
4+
};
5+
6+
export type MarketplaceFeedProfileConfig = {
7+
url: string;
8+
verification?: MarketplaceFeedVerificationConfig;
9+
};
10+
11+
export type MarketplaceSourceProfileConfig =
12+
| {
13+
type: "npm";
14+
}
15+
| {
16+
type: "clawhub";
17+
}
18+
| {
19+
type: "git";
20+
};
21+
22+
export type MarketplacesConfig = {
23+
feeds?: Record<string, MarketplaceFeedProfileConfig>;
24+
sources?: Record<string, MarketplaceSourceProfileConfig>;
25+
};

src/config/types.openclaw.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type { CrestodianConfig } from "./types.crestodian.js";
1515
import type { CronConfig } from "./types.cron.js";
1616
import type { DiscoveryConfig, GatewayConfig, TalkConfig } from "./types.gateway.js";
1717
import type { HooksConfig } from "./types.hooks.js";
18+
import type { MarketplacesConfig } from "./types.marketplaces.js";
1819
import type { McpConfig } from "./types.mcp.js";
1920
import type { MemoryConfig } from "./types.memory.js";
2021
import type {
@@ -176,6 +177,8 @@ export type OpenClawConfig = {
176177
};
177178
/** Secret providers, defaults, and ref-resolution settings. */
178179
secrets?: SecretsConfig;
180+
/** Marketplace feed and local package source profile configuration. */
181+
marketplaces?: MarketplacesConfig;
179182
/** Skill loading and bundled skill configuration. */
180183
skills?: SkillsConfig;
181184
/** Plugin registry/install/runtime configuration. */

src/config/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export * from "./types.approvals.js";
88
export * from "./types.auth.js";
99
export * from "./types.base.js";
1010
export * from "./types.browser.js";
11+
export * from "./types.marketplaces.js";
1112
export * from "./types.channels.js";
1213
export * from "./types.cli.js";
1314
export * from "./types.commitments.js";
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
// Verifies marketplace feed and source profile config parsing.
2+
import { describe, expect, it } from "vitest";
3+
import { OpenClawSchema } from "./zod-schema.js";
4+
5+
function expectMarketplacesConfig(value: unknown) {
6+
const result = OpenClawSchema.safeParse(value);
7+
if (!result.success) {
8+
throw new Error(JSON.stringify(result.error.issues, null, 2));
9+
}
10+
return result.data.marketplaces;
11+
}
12+
13+
describe("OpenClawSchema marketplaces config", () => {
14+
it("accepts hosted feed and local source profiles", () => {
15+
const marketplaces = expectMarketplacesConfig({
16+
marketplaces: {
17+
feeds: {
18+
"clawhub-public": {
19+
url: "https://clawhub.ai/v1/feeds/plugins",
20+
verification: { mode: "unsigned" },
21+
},
22+
acme: {
23+
url: "https://packages.acme.example/openclaw/feed",
24+
verification: { mode: "unsigned" },
25+
},
26+
},
27+
sources: {
28+
"public-clawhub": { type: "clawhub" },
29+
"public-npm": { type: "npm" },
30+
"acme-npm": { type: "npm" },
31+
"acme-clawhub": { type: "clawhub" },
32+
"acme-git": { type: "git" },
33+
},
34+
},
35+
});
36+
37+
expect(marketplaces?.feeds?.acme.url).toBe("https://packages.acme.example/openclaw/feed");
38+
expect(marketplaces?.sources?.["acme-git"].type).toBe("git");
39+
});
40+
41+
it.each([
42+
"http://packages.acme.example/openclaw/feed",
43+
"https://[email protected]/openclaw/feed",
44+
"https://user:[email protected]/openclaw/feed",
45+
"https://packages.acme.example/openclaw/feed?token=secret",
46+
"https://packages.acme.example/openclaw/feed#access-token",
47+
"not a url",
48+
])("rejects invalid or auth-bearing hosted feed URL %s without throwing", (url) => {
49+
expect(() =>
50+
OpenClawSchema.safeParse({
51+
marketplaces: {
52+
feeds: { acme: { url } },
53+
},
54+
}),
55+
).not.toThrow();
56+
const result = OpenClawSchema.safeParse({
57+
marketplaces: {
58+
feeds: { acme: { url } },
59+
},
60+
});
61+
62+
expect(result.success).toBe(false);
63+
if (!result.success) {
64+
expect(result.error.issues.map((issue) => issue.path.join("."))).toContain(
65+
"marketplaces.feeds.acme.url",
66+
);
67+
}
68+
});
69+
70+
it("rejects refresh, auth, and signed verification until loader enforcement exists", () => {
71+
expect(
72+
OpenClawSchema.safeParse({
73+
marketplaces: {
74+
feeds: {
75+
acme: {
76+
url: "https://packages.acme.example/openclaw/feed",
77+
auth: { scheme: "bearer", secret: "token" },
78+
},
79+
},
80+
},
81+
}).success,
82+
).toBe(false);
83+
expect(
84+
OpenClawSchema.safeParse({
85+
marketplaces: {
86+
feeds: {
87+
acme: {
88+
url: "https://packages.acme.example/openclaw/feed",
89+
refresh: { onStartup: "if-stale" },
90+
},
91+
},
92+
},
93+
}).success,
94+
).toBe(false);
95+
expect(
96+
OpenClawSchema.safeParse({
97+
marketplaces: {
98+
feeds: {
99+
acme: {
100+
url: "https://packages.acme.example/openclaw/feed",
101+
verification: { mode: "signed" },
102+
},
103+
},
104+
},
105+
}).success,
106+
).toBe(false);
107+
});
108+
109+
it("rejects unknown source profile types", () => {
110+
const result = OpenClawSchema.safeParse({
111+
marketplaces: {
112+
sources: { acme: { type: "container" } },
113+
},
114+
});
115+
116+
expect(result.success).toBe(false);
117+
});
118+
119+
it("rejects source endpoints until installer resolution can enforce them", () => {
120+
const result = OpenClawSchema.safeParse({
121+
marketplaces: {
122+
sources: {
123+
"acme-npm": { type: "npm", registry: "https://packages.acme.example/npm/" },
124+
"acme-clawhub": { type: "clawhub", baseUrl: "https://packages.acme.example/clawhub/" },
125+
},
126+
},
127+
});
128+
129+
expect(result.success).toBe(false);
130+
});
131+
});

src/config/zod-schema.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,48 @@ const CrestodianSchema = z
484484
.strict()
485485
.optional();
486486

487+
function isPlainHttpsUrl(value: string): boolean {
488+
try {
489+
const url = new URL(value);
490+
return url.protocol === "https:" && !url.username && !url.password && !url.search && !url.hash;
491+
} catch {
492+
return false;
493+
}
494+
}
495+
496+
const MarketplaceVerificationSchema = z
497+
.object({
498+
mode: z.literal("unsigned"),
499+
})
500+
.strict();
501+
502+
const MarketplaceFeedProfileSchema = z
503+
.object({
504+
url: z
505+
.string()
506+
.url()
507+
.refine(
508+
(value) => isPlainHttpsUrl(value),
509+
"Expected https:// URL without credentials, query, or fragment",
510+
),
511+
verification: MarketplaceVerificationSchema.optional(),
512+
})
513+
.strict();
514+
515+
const MarketplaceSourceProfileSchema = z.union([
516+
z.object({ type: z.literal("npm") }).strict(),
517+
z.object({ type: z.literal("clawhub") }).strict(),
518+
z.object({ type: z.literal("git") }).strict(),
519+
]);
520+
521+
const MarketplacesSchema = z
522+
.object({
523+
feeds: z.record(z.string().min(1), MarketplaceFeedProfileSchema).optional(),
524+
sources: z.record(z.string().min(1), MarketplaceSourceProfileSchema).optional(),
525+
})
526+
.strict()
527+
.optional();
528+
487529
const CommitmentsSchema = z
488530
.object({
489531
enabled: z.boolean().optional(),
@@ -739,6 +781,7 @@ export const OpenClawSchema = z
739781
.strict()
740782
.optional(),
741783
secrets: SecretsConfigSchema,
784+
marketplaces: MarketplacesSchema,
742785
auth: z
743786
.object({
744787
profiles: z

src/plugins/official-external-plugin-catalog.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
isOfficialExternalPluginCatalogFeed,
1414
filterOfficialExternalPluginCatalogEntriesBySourceRefs,
1515
listOfficialExternalPluginCatalogEntries,
16+
loadConfiguredHostedOfficialExternalPluginCatalogEntries,
1617
loadHostedOfficialExternalPluginCatalogEntries,
1718
parseOfficialExternalPluginCatalogEntries,
1819
resolveOfficialExternalProviderContractPluginIds,
@@ -21,6 +22,7 @@ import {
2122
resolveOfficialExternalWebProviderContractPluginIdsForEnv,
2223
resolveOfficialExternalPluginId,
2324
resolveOfficialExternalPluginInstall,
25+
resolveOfficialExternalPluginCatalogProfileConfigFromConfig,
2426
validateOfficialExternalPluginCatalogEntrySourceRefs,
2527
} from "./official-external-plugin-catalog.js";
2628

@@ -464,6 +466,47 @@ describe("official external plugin catalog", () => {
464466
});
465467
});
466468

469+
it("loads hosted catalog profiles from OpenClaw config", async () => {
470+
const config = {
471+
marketplaces: {
472+
feeds: { acme: { url: "https://packages.acme.example/openclaw/feed" } },
473+
sources: { "acme-npm": { type: "npm" as const } },
474+
},
475+
};
476+
const body = JSON.stringify({
477+
schemaVersion: 1,
478+
id: "openclaw-official-external-plugins",
479+
generatedAt: "2026-06-22T00:00:00.000Z",
480+
sequence: 14,
481+
entries: [
482+
{
483+
name: "@acme/config-profile-proof",
484+
kind: "plugin",
485+
openclaw: {
486+
plugin: { id: "config-profile-proof" },
487+
install: { sourceRef: "acme-npm", npmSpec: "@acme/config-profile-proof" },
488+
},
489+
},
490+
],
491+
});
492+
493+
expect(resolveOfficialExternalPluginCatalogProfileConfigFromConfig(config)).toBe(
494+
config.marketplaces,
495+
);
496+
497+
const result = await loadConfiguredHostedOfficialExternalPluginCatalogEntries(config, {
498+
feedProfile: "acme",
499+
fetchImpl: vi.fn(async (url: RequestInfo | URL) => {
500+
expect(expectRequestUrl(url)).toBe("https://packages.acme.example/openclaw/feed");
501+
return new Response(body, { status: 200 });
502+
}),
503+
snapshotStore: null,
504+
});
505+
506+
expect(result.source).toBe("hosted");
507+
expect(result.entries.map((entry) => entry.name)).toEqual(["@acme/config-profile-proof"]);
508+
});
509+
467510
it("allows named local feed profiles to authorize their configured HTTPS host", async () => {
468511
const body = JSON.stringify({
469512
schemaVersion: 1,

0 commit comments

Comments
 (0)