mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 10:58:37 +00:00
Add hosted catalog config profiles
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
25
src/config/types.marketplaces.ts
Normal file
25
src/config/types.marketplaces.ts
Normal 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>;
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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";
|
||||
|
||||
129
src/config/zod-schema.marketplaces.test.ts
Normal file
129
src/config/zod-schema.marketplaces.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user