mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
1ca60fbc3a
* refactor(agents): make roster ownership explicit * feat(config): materialize legacy agent roles * fix(cron): migrate legacy owners at startup * feat(gateway): expose agent selection contracts * fix(gateway): enforce agent-scoped authorization * docs(config): document explicit agent ownership * fix(config): pin retained owner workspace * fix(gateway): target hook wakes at effective agent * fix(sessions): preserve fixed-store ownership * fix: preserve retained agent ownership * fix: preserve legacy agent ownership across runtime surfaces * fix: fail closed on ambiguous session ownership * fix: preserve compatibility owners across dispatch and writes * fix: preserve retained agent projections * fix: preserve agent ownership compatibility * fix: preserve per-agent heartbeat guidance * fix: preserve compatibility owners in generic paths * fix: enforce configured ownership in session paths * fix: defer remote roster selection * fix: preserve ownership across session and config writes * fix: fail closed on ambiguous restored ownership * fix: preserve explicit ACP and legacy ownership * fix: honor durable fixed-store ownership * fix: enforce fixed-store owner authority * fix: preserve ownership evidence boundaries * fix: honor resolved session ownership * fix: align compatibility ownership paths * fix: persist legacy main store ownership * fix: close ownership fallback gaps * fix(agents): close retained owner compatibility gaps * fix(agents): enforce session owner resolution * fix(agents): complete session owner resolution sweep * fix(agents): preserve durable session ownership * fix: complete persisted session owner routing * fix: thread prepared session owners * fix: preserve stable session ownership * fix: enforce session ownership boundaries * fix: close session ownership delta gaps * fix: reconcile session ownership after rebase * fix: reconcile ownership with current main * fix: align session store path imports * fix: align session store config path import * fix: reconcile explicit ownership CI * fix: reconcile ownership rebase checks * fix: align ownership ci contracts * fix: align ownership rebase checks * fix: preserve compatibility owner during setup * fix(doctor): migrate ownerless heartbeat monitors * fix(gateway): preserve explicit session ownership * test: align ownership fixtures after rebase * test: complete plugin manifest fixture * test: align runtime context mocks * fix(gateway): preserve alias routing for existing sessions * style: format agent routing update * fix(gateway): preserve selected owner during alias routing * style: normalize rebased ownership files * fix(gateway): preserve owner through global alias routing * fix(gateway): preserve explicit ownership at HTTP boundaries * fix(gateway): validate compatibility model ownership * fix(agents): reconcile strict session ownership * fix(agents): contain media yield callback failures * fix(agents): avoid eager bare-key owner resolution * chore: refresh rebased ownership baselines * chore: align hosted plugin SDK baseline * chore: refresh ownership baselines after main sync * chore: refresh ownership baselines after main sync * test: align routed event owner fixtures * chore: retrigger CI after runner startup failure * chore: refresh ownership SDK budgets after main sync * fix(tasks): require agent identity for bare owners * chore: align Linux plugin SDK baseline * chore: remove release-owned changelog entry
405 lines
12 KiB
TypeScript
405 lines
12 KiB
TypeScript
// Covers plugin config contract validation and ownership boundaries.
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { PluginManifestRegistry } from "./manifest-registry.js";
|
|
|
|
const mocks = vi.hoisted(() => {
|
|
const loadManifestRegistry = vi.fn();
|
|
return {
|
|
discoverOpenClawPlugins: vi.fn(() => ({ candidates: [], diagnostics: [] })),
|
|
findBundledPluginMetadataById: vi.fn(),
|
|
loadBundledManifestRegistry: vi.fn(),
|
|
loadPluginManifestRegistryForInstalledIndex: loadManifestRegistry,
|
|
loadPluginManifestRegistryForPluginRegistry: loadManifestRegistry,
|
|
loadPluginRegistrySnapshot: vi.fn(() => ({ plugins: [] })),
|
|
};
|
|
});
|
|
|
|
vi.mock("./discovery.js", () => ({
|
|
discoverOpenClawPlugins: mocks.discoverOpenClawPlugins,
|
|
}));
|
|
|
|
vi.mock("./bundled-plugin-metadata.js", () => ({
|
|
findBundledPluginMetadataById: mocks.findBundledPluginMetadataById,
|
|
}));
|
|
|
|
vi.mock("./manifest-registry.js", () => ({
|
|
loadPluginManifestRegistryCore: mocks.loadBundledManifestRegistry,
|
|
}));
|
|
|
|
vi.mock("./manifest-registry-installed.js", () => ({
|
|
loadPluginManifestRegistryForInstalledIndex: mocks.loadPluginManifestRegistryForInstalledIndex,
|
|
}));
|
|
|
|
vi.mock("./plugin-registry.js", () => ({
|
|
loadPluginManifestRegistryForPluginRegistry: mocks.loadPluginManifestRegistryForPluginRegistry,
|
|
loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot,
|
|
}));
|
|
|
|
import {
|
|
collectPluginConfigContractMatches,
|
|
resolvePluginConfigContractsById,
|
|
} from "./config-contracts.js";
|
|
|
|
type PluginManifestRecord = PluginManifestRegistry["plugins"][number];
|
|
|
|
function createRegistry(plugins: PluginManifestRegistry["plugins"]): PluginManifestRegistry {
|
|
return {
|
|
plugins,
|
|
diagnostics: [],
|
|
};
|
|
}
|
|
|
|
function createPluginRecord(
|
|
overrides: Pick<PluginManifestRecord, "id" | "origin"> & Partial<PluginManifestRecord>,
|
|
): PluginManifestRecord {
|
|
return {
|
|
rootDir: `/tmp/${overrides.id}`,
|
|
manifestPath: `/tmp/${overrides.id}/openclaw.plugin.json`,
|
|
channelConfigs: undefined,
|
|
configUiHints: undefined,
|
|
configSchema: undefined,
|
|
configContracts: undefined,
|
|
contracts: undefined,
|
|
name: undefined,
|
|
description: undefined,
|
|
version: undefined,
|
|
enabledByDefault: undefined,
|
|
autoEnableWhenConfiguredProviders: undefined,
|
|
legacyPluginIds: undefined,
|
|
format: undefined,
|
|
bundleFormat: undefined,
|
|
bundleCapabilities: undefined,
|
|
kind: undefined,
|
|
channels: [],
|
|
providers: [],
|
|
modelSupport: undefined,
|
|
cliBackends: [],
|
|
providerAuthAliases: undefined,
|
|
providerAuthChoices: undefined,
|
|
skills: [],
|
|
settingsFiles: undefined,
|
|
hooks: [],
|
|
source: `/tmp/${overrides.id}/openclaw.plugin.json`,
|
|
setupSource: undefined,
|
|
channelCatalogMeta: undefined,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("resolvePluginConfigContractsById", () => {
|
|
beforeEach(() => {
|
|
mocks.discoverOpenClawPlugins.mockReset();
|
|
mocks.discoverOpenClawPlugins.mockReturnValue({ candidates: [], diagnostics: [] });
|
|
mocks.findBundledPluginMetadataById.mockReset();
|
|
mocks.loadBundledManifestRegistry.mockReset();
|
|
mocks.loadBundledManifestRegistry.mockReturnValue(createRegistry([]));
|
|
mocks.loadPluginManifestRegistryForInstalledIndex.mockReset();
|
|
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue(createRegistry([]));
|
|
mocks.loadPluginRegistrySnapshot.mockReset();
|
|
mocks.loadPluginRegistrySnapshot.mockReturnValue({ plugins: [] });
|
|
});
|
|
|
|
it("uses a supplied manifest registry as the authoritative contract source", () => {
|
|
const manifestRegistry = createRegistry([
|
|
createPluginRecord({
|
|
id: "prepared-plugin",
|
|
origin: "config",
|
|
configContracts: {
|
|
secretInputs: {
|
|
paths: [{ path: "credentials.token", expected: "string" }],
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
expect(
|
|
resolvePluginConfigContractsById({
|
|
pluginIds: ["prepared-plugin"],
|
|
manifestRegistry,
|
|
fallbackToBundledMetadata: true,
|
|
fallbackToBundledMetadataForResolvedBundled: true,
|
|
fallbackBundledPluginIds: ["prepared-plugin"],
|
|
}),
|
|
).toEqual(
|
|
new Map([
|
|
[
|
|
"prepared-plugin",
|
|
{
|
|
origin: "config",
|
|
configContracts: {
|
|
secretInputs: {
|
|
paths: [{ path: "credentials.token", expected: "string" }],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
expect(mocks.loadPluginManifestRegistryForPluginRegistry).not.toHaveBeenCalled();
|
|
expect(mocks.discoverOpenClawPlugins).not.toHaveBeenCalled();
|
|
expect(mocks.loadBundledManifestRegistry).not.toHaveBeenCalled();
|
|
expect(mocks.findBundledPluginMetadataById).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not fall back to bundled registry when registry already resolved a plugin without config contracts", () => {
|
|
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue(
|
|
createRegistry([
|
|
createPluginRecord({
|
|
id: "brave",
|
|
origin: "bundled",
|
|
}),
|
|
]),
|
|
);
|
|
|
|
expect(
|
|
resolvePluginConfigContractsById({
|
|
pluginIds: ["brave"],
|
|
}),
|
|
).toEqual(new Map());
|
|
expect(mocks.loadBundledManifestRegistry).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("hydrates supplied bundled registry records from explicit bundled discovery", () => {
|
|
mocks.loadBundledManifestRegistry.mockReturnValue(
|
|
createRegistry([
|
|
createPluginRecord({
|
|
id: "prepared-plugin",
|
|
origin: "bundled",
|
|
configContracts: {
|
|
secretInputs: {
|
|
paths: [{ path: "credentials.token", expected: "string" }],
|
|
},
|
|
},
|
|
}),
|
|
]),
|
|
);
|
|
|
|
expect(
|
|
resolvePluginConfigContractsById({
|
|
pluginIds: ["prepared-plugin"],
|
|
manifestRegistry: createRegistry([
|
|
createPluginRecord({ id: "prepared-plugin", origin: "bundled" }),
|
|
]),
|
|
fallbackToBundledMetadata: true,
|
|
fallbackToBundledMetadataForResolvedBundled: true,
|
|
fallbackBundledPluginIds: ["prepared-plugin"],
|
|
}),
|
|
).toEqual(
|
|
new Map([
|
|
[
|
|
"prepared-plugin",
|
|
{
|
|
origin: "bundled",
|
|
configContracts: {
|
|
secretInputs: {
|
|
paths: [{ path: "credentials.token", expected: "string" }],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("can hydrate missing contracts from bundled registry for resolved bundled plugins", () => {
|
|
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue(
|
|
createRegistry([
|
|
createPluginRecord({
|
|
id: "voice-call",
|
|
origin: "bundled",
|
|
configContracts: {
|
|
compatibilityMigrationPaths: ["plugins.entries.voice-call.config"],
|
|
},
|
|
}),
|
|
]),
|
|
);
|
|
mocks.loadBundledManifestRegistry.mockReturnValue(
|
|
createRegistry([
|
|
createPluginRecord({
|
|
id: "voice-call",
|
|
origin: "bundled",
|
|
configContracts: {
|
|
secretInputs: {
|
|
paths: [{ path: "twilio.authToken", expected: "string" }],
|
|
},
|
|
},
|
|
}),
|
|
]),
|
|
);
|
|
|
|
expect(
|
|
resolvePluginConfigContractsById({
|
|
pluginIds: ["voice-call"],
|
|
fallbackToBundledMetadataForResolvedBundled: true,
|
|
}),
|
|
).toEqual(
|
|
new Map([
|
|
[
|
|
"voice-call",
|
|
{
|
|
origin: "bundled",
|
|
configContracts: {
|
|
compatibilityMigrationPaths: ["plugins.entries.voice-call.config"],
|
|
secretInputs: {
|
|
paths: [{ path: "twilio.authToken", expected: "string" }],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
expect(mocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledTimes(1);
|
|
expect(mocks.loadBundledManifestRegistry).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("refreshes stale bundled SecretInput contracts from bundled registry", () => {
|
|
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue(
|
|
createRegistry([
|
|
createPluginRecord({
|
|
id: "voice-call",
|
|
origin: "bundled",
|
|
configContracts: {
|
|
compatibilityMigrationPaths: ["plugins.entries.voice-call.config"],
|
|
secretInputs: {
|
|
paths: [{ path: "twilio.authToken", expected: "string" }],
|
|
},
|
|
},
|
|
}),
|
|
]),
|
|
);
|
|
mocks.loadBundledManifestRegistry.mockReturnValue(
|
|
createRegistry([
|
|
createPluginRecord({
|
|
id: "voice-call",
|
|
origin: "bundled",
|
|
configContracts: {
|
|
secretInputs: {
|
|
paths: [
|
|
{ path: "twilio.authToken", expected: "string" },
|
|
{ path: "realtime.providers.*.apiKey", expected: "string" },
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
]),
|
|
);
|
|
|
|
expect(
|
|
resolvePluginConfigContractsById({
|
|
pluginIds: ["voice-call"],
|
|
fallbackToBundledMetadataForResolvedBundled: true,
|
|
}),
|
|
).toEqual(
|
|
new Map([
|
|
[
|
|
"voice-call",
|
|
{
|
|
origin: "bundled",
|
|
configContracts: {
|
|
compatibilityMigrationPaths: ["plugins.entries.voice-call.config"],
|
|
secretInputs: {
|
|
paths: [
|
|
{ path: "twilio.authToken", expected: "string" },
|
|
{ path: "realtime.providers.*.apiKey", expected: "string" },
|
|
],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("can hydrate missing contracts for plugin ids known to be bundled by runtime discovery", () => {
|
|
mocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue(
|
|
createRegistry([
|
|
createPluginRecord({
|
|
id: "voice-call",
|
|
origin: "config",
|
|
}),
|
|
]),
|
|
);
|
|
mocks.loadBundledManifestRegistry.mockReturnValue(
|
|
createRegistry([
|
|
createPluginRecord({
|
|
id: "voice-call",
|
|
origin: "bundled",
|
|
configContracts: {
|
|
secretInputs: {
|
|
paths: [{ path: "tts.providers.*.apiKey", expected: "string" }],
|
|
},
|
|
},
|
|
}),
|
|
]),
|
|
);
|
|
|
|
expect(
|
|
resolvePluginConfigContractsById({
|
|
pluginIds: ["voice-call"],
|
|
fallbackBundledPluginIds: ["voice-call"],
|
|
}),
|
|
).toEqual(
|
|
new Map([
|
|
[
|
|
"voice-call",
|
|
{
|
|
origin: "bundled",
|
|
configContracts: {
|
|
secretInputs: {
|
|
paths: [{ path: "tts.providers.*.apiKey", expected: "string" }],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("can skip bundled metadata fallback for registry-scoped callers", () => {
|
|
expect(
|
|
resolvePluginConfigContractsById({
|
|
pluginIds: ["missing"],
|
|
fallbackToBundledMetadata: false,
|
|
}),
|
|
).toEqual(new Map());
|
|
expect(mocks.loadBundledManifestRegistry).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("collectPluginConfigContractMatches", () => {
|
|
it("only accepts canonical array index path segments", () => {
|
|
const root = { items: ["first", "second"] };
|
|
|
|
expect(
|
|
collectPluginConfigContractMatches({
|
|
root,
|
|
pathPattern: "items.1",
|
|
}),
|
|
).toEqual([{ path: "items[1]", value: "second" }]);
|
|
expect(
|
|
collectPluginConfigContractMatches({
|
|
root,
|
|
pathPattern: "items.1.5",
|
|
}),
|
|
).toEqual([]);
|
|
expect(
|
|
collectPluginConfigContractMatches({
|
|
root,
|
|
pathPattern: "items.01",
|
|
}),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("rejects array indexes outside canonical config path bounds", () => {
|
|
const items = Array<string>(100_002);
|
|
items[100_001] = "too far";
|
|
|
|
expect(
|
|
collectPluginConfigContractMatches({
|
|
root: { items },
|
|
pathPattern: "items.100001",
|
|
}),
|
|
).toEqual([]);
|
|
});
|
|
});
|