mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 23:11:35 +00:00
fix(reef): migrate legacy peer trust config (#108417)
* fix(reef): migrate legacy peer trust config * chore(reef): leave changelog to release flow * build(reef): refresh bundled config metadata * fix(reef): keep identity scope internal
This commit is contained in:
committed by
GitHub
parent
f4f5bb15d4
commit
ff4d854167
200
extensions/reef/doctor-contract-api.test.ts
Normal file
200
extensions/reef/doctor-contract-api.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import {
|
||||
createPluginStateKeyedStoreForTests,
|
||||
resetPluginStateStoreForTests,
|
||||
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginDoctorStateMigrationContext,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
legacyConfigRules,
|
||||
normalizeCompatibilityConfig,
|
||||
stateMigrations,
|
||||
} from "./doctor-contract-api.js";
|
||||
import { generateIdentity } from "./protocol/index.js";
|
||||
import { ReefChannelConfigSchema } from "./src/config-schema.js";
|
||||
import {
|
||||
REEF_TRUST_STORE_MAX_ENTRIES,
|
||||
REEF_TRUST_STORE_NAMESPACE,
|
||||
resolveReefTrustStoreKey,
|
||||
} from "./src/trust-store.js";
|
||||
|
||||
function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext {
|
||||
return {
|
||||
openPluginStateKeyedStore<T>(options: OpenKeyedStoreOptions) {
|
||||
return createPluginStateKeyedStoreForTests<T>("reef", {
|
||||
...options,
|
||||
env: options.env ?? env,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function legacyConfig(): OpenClawConfig {
|
||||
const identity = generateIdentity();
|
||||
return {
|
||||
channels: {
|
||||
reef: {
|
||||
enabled: true,
|
||||
handle: "owner",
|
||||
relayUrl: "https://reefwire.ai",
|
||||
requestPolicy: "code-only",
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["peer"],
|
||||
friends: {
|
||||
peer: {
|
||||
autonomy: "extended",
|
||||
ed25519PublicKey: identity.signing.publicKey,
|
||||
x25519PublicKey: identity.encryption.publicKey,
|
||||
keyEpoch: 2,
|
||||
safetyNumberChanged: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("Reef doctor contract", () => {
|
||||
let stateDir = "";
|
||||
let env: NodeJS.ProcessEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
resetPluginStateStoreForTests();
|
||||
stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-reef-doctor-"));
|
||||
env = { ...process.env, OPENCLAW_STATE_DIR: stateDir };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetPluginStateStoreForTests();
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("detects and removes retired config fields", () => {
|
||||
const cfg = legacyConfig();
|
||||
expect(legacyConfigRules[0]?.match?.(cfg.channels?.reef, cfg)).toBe(true);
|
||||
|
||||
const result = normalizeCompatibilityConfig({ cfg });
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
"Removed retired Reef dmPolicy field.",
|
||||
"Removed retired Reef allowFrom field.",
|
||||
]);
|
||||
expect(result.config.channels?.reef).toEqual({
|
||||
enabled: true,
|
||||
handle: "owner",
|
||||
relayUrl: "https://reefwire.ai",
|
||||
requestPolicy: "code-only",
|
||||
friends: expect.any(Object),
|
||||
});
|
||||
});
|
||||
|
||||
it("imports config-backed trust into scoped plugin state without overwriting canonical rows", async () => {
|
||||
const cfg = legacyConfig();
|
||||
const migration = stateMigrations[0]!;
|
||||
const context = createDoctorContext(env);
|
||||
const params = { config: cfg, env, stateDir, oauthDir: path.join(stateDir, "oauth"), context };
|
||||
|
||||
await expect(migration.detectLegacyState(params)).resolves.toEqual({
|
||||
preview: ["- Reef peer trust: config -> plugin state (1 peer(s), 0 invalid)"],
|
||||
});
|
||||
await expect(migration.migrateLegacyState(params)).resolves.toEqual({
|
||||
changes: ["Migrated Reef peer trust -> plugin state (1 imported, 0 already present)"],
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
const canonical = ReefChannelConfigSchema.parse({
|
||||
handle: "owner",
|
||||
relayUrl: "https://reefwire.ai",
|
||||
requestPolicy: "code-only",
|
||||
});
|
||||
const store = context.openPluginStateKeyedStore<{
|
||||
revision: number;
|
||||
trust: { autonomy: string; approvedAt: number };
|
||||
}>({
|
||||
namespace: REEF_TRUST_STORE_NAMESPACE,
|
||||
maxEntries: REEF_TRUST_STORE_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
const peerKey = resolveReefTrustStoreKey(canonical, "peer");
|
||||
await expect(store.lookup(peerKey)).resolves.toMatchObject({
|
||||
revision: 1,
|
||||
trust: { autonomy: "extended", approvedAt: 0 },
|
||||
});
|
||||
await expect(migration.detectLegacyState(params)).resolves.toBeNull();
|
||||
await expect(migration.migrateLegacyState(params)).resolves.toEqual({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
});
|
||||
await store.delete(peerKey);
|
||||
await expect(migration.migrateLegacyState(params)).resolves.toEqual({
|
||||
changes: [],
|
||||
warnings: [],
|
||||
});
|
||||
await expect(store.lookup(peerKey)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("migrates valid rows but retains the legacy map when another row is invalid", async () => {
|
||||
const cfg = legacyConfig();
|
||||
const reef = cfg.channels?.reef as Record<string, unknown>;
|
||||
reef.friends = {
|
||||
...(reef.friends as Record<string, unknown>),
|
||||
broken: { autonomy: "extended" },
|
||||
};
|
||||
const migration = stateMigrations[0]!;
|
||||
const context = createDoctorContext(env);
|
||||
const params = { config: cfg, env, stateDir, oauthDir: path.join(stateDir, "oauth"), context };
|
||||
|
||||
await expect(migration.detectLegacyState(params)).resolves.toEqual({
|
||||
preview: ["- Reef peer trust: config -> plugin state (1 peer(s), 1 invalid)"],
|
||||
});
|
||||
await expect(migration.migrateLegacyState(params)).resolves.toEqual({
|
||||
changes: ["Migrated Reef peer trust -> plugin state (1 imported, 0 already present)"],
|
||||
warnings: ["Skipped 1 invalid Reef peer trust row(s); left legacy friends config in place"],
|
||||
});
|
||||
|
||||
const normalized = normalizeCompatibilityConfig({ cfg });
|
||||
expect(normalized.config.channels?.reef).toHaveProperty("friends.broken");
|
||||
expect(normalized.config.channels?.reef).not.toHaveProperty("dmPolicy");
|
||||
expect(normalized.config.channels?.reef).not.toHaveProperty("allowFrom");
|
||||
});
|
||||
|
||||
it("does not partially migrate when the trust namespace is full", async () => {
|
||||
const cfg = legacyConfig();
|
||||
const registerIfAbsent = vi.fn();
|
||||
const context = {
|
||||
openPluginStateKeyedStore() {
|
||||
return {
|
||||
entries: async () =>
|
||||
Array.from({ length: REEF_TRUST_STORE_MAX_ENTRIES }, (_, index) => ({
|
||||
key: `existing-${index}`,
|
||||
value: {},
|
||||
createdAt: 0,
|
||||
})),
|
||||
registerIfAbsent,
|
||||
} as never;
|
||||
},
|
||||
} as PluginDoctorStateMigrationContext;
|
||||
|
||||
await expect(
|
||||
stateMigrations[0]!.migrateLegacyState({
|
||||
config: cfg,
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
changes: [],
|
||||
warnings: [
|
||||
"Skipped Reef peer trust migration because plugin state has room for 0 of 1 trust row(s) and 0 of 1 import marker(s); left legacy friends config in place",
|
||||
],
|
||||
});
|
||||
expect(registerIfAbsent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
198
extensions/reef/doctor-contract-api.ts
Normal file
198
extensions/reef/doctor-contract-api.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
import type { ChannelDoctorLegacyConfigRule } from "openclaw/plugin-sdk/channel-contract";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { isRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { z } from "zod";
|
||||
import { ReefChannelConfigSchema, normalizeReefTarget } from "./src/config-schema.js";
|
||||
import { ReefPeerTrustSchema, type ReefPeerTrust } from "./src/friend-types.js";
|
||||
import {
|
||||
REEF_TRUST_STORE_MAX_ENTRIES,
|
||||
REEF_TRUST_STORE_NAMESPACE,
|
||||
resolveReefTrustStoreKey,
|
||||
} from "./src/trust-store.js";
|
||||
|
||||
const RETIRED_REEF_CONFIG_KEYS = ["friends", "dmPolicy", "allowFrom"] as const;
|
||||
const REEF_CONFIG_IMPORT_NAMESPACE = "peer-state-config-imports";
|
||||
const LegacyReefFriendSchema = ReefPeerTrustSchema.omit({ approvedAt: true });
|
||||
|
||||
type ReefPeerStateSnapshot = {
|
||||
revision: number;
|
||||
trust: ReefPeerTrust;
|
||||
};
|
||||
|
||||
type ReefConfigImportMarker = {
|
||||
version: 1;
|
||||
importedAt: number;
|
||||
};
|
||||
|
||||
function hasRetiredReefPolicyConfig(value: unknown): boolean {
|
||||
return isRecord(value) && ["dmPolicy", "allowFrom"].some((key) => Object.hasOwn(value, key));
|
||||
}
|
||||
|
||||
function inspectLegacyReefFriends(cfg: OpenClawConfig) {
|
||||
const reef = cfg.channels?.reef;
|
||||
if (!isRecord(reef) || !Object.hasOwn(reef, "friends")) {
|
||||
return null;
|
||||
}
|
||||
const rawFriends = isRecord(reef.friends) ? reef.friends : null;
|
||||
const canonicalCandidate = { ...reef };
|
||||
for (const key of RETIRED_REEF_CONFIG_KEYS) {
|
||||
delete canonicalCandidate[key];
|
||||
}
|
||||
const parsedConfig = ReefChannelConfigSchema.safeParse(canonicalCandidate);
|
||||
const config = parsedConfig.success && parsedConfig.data.handle ? parsedConfig.data : null;
|
||||
const friends = new Map<string, z.infer<typeof LegacyReefFriendSchema>>();
|
||||
let rejected = rawFriends ? 0 : 1;
|
||||
for (const [peer, value] of Object.entries(rawFriends ?? {})) {
|
||||
const parsedFriend = LegacyReefFriendSchema.safeParse(value);
|
||||
if (normalizeReefTarget(peer) !== peer || !parsedFriend.success) {
|
||||
rejected++;
|
||||
continue;
|
||||
}
|
||||
friends.set(peer, parsedFriend.data);
|
||||
}
|
||||
return { config, friends, rejected, total: rawFriends ? Object.keys(rawFriends).length : 0 };
|
||||
}
|
||||
|
||||
export const legacyConfigRules: ChannelDoctorLegacyConfigRule[] = [
|
||||
{
|
||||
path: ["channels", "reef"],
|
||||
message:
|
||||
'channels.reef dmPolicy/allowFrom are legacy; run "openclaw doctor --fix" to remove them. Peer trust is SQLite-backed.',
|
||||
match: hasRetiredReefPolicyConfig,
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
} {
|
||||
const reef = cfg.channels?.reef;
|
||||
if (!isRecord(reef) || !hasRetiredReefPolicyConfig(reef)) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
const next = structuredClone(cfg);
|
||||
const nextReef = next.channels?.reef;
|
||||
if (!isRecord(nextReef)) {
|
||||
return { config: cfg, changes: [] };
|
||||
}
|
||||
const changes: string[] = [];
|
||||
for (const key of ["dmPolicy", "allowFrom"] as const) {
|
||||
if (Object.hasOwn(nextReef, key)) {
|
||||
delete nextReef[key];
|
||||
changes.push(`Removed retired Reef ${key} field.`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
config: next,
|
||||
changes,
|
||||
};
|
||||
}
|
||||
|
||||
export const stateMigrations: PluginDoctorStateMigration[] = [
|
||||
{
|
||||
id: "reef-config-trust-to-plugin-state",
|
||||
label: "Reef peer trust",
|
||||
async detectLegacyState({ config, context }) {
|
||||
const legacy = inspectLegacyReefFriends(config);
|
||||
const markerStore = context.openPluginStateKeyedStore<ReefConfigImportMarker>({
|
||||
namespace: REEF_CONFIG_IMPORT_NAMESPACE,
|
||||
maxEntries: REEF_TRUST_STORE_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
const markedKeys = new Set((await markerStore.entries()).map((entry) => entry.key));
|
||||
const legacyConfig = legacy?.config;
|
||||
const count = legacyConfig
|
||||
? [...legacy.friends.keys()].filter(
|
||||
(peer) => !markedKeys.has(resolveReefTrustStoreKey(legacyConfig, peer)),
|
||||
).length
|
||||
: (legacy?.friends.size ?? 0);
|
||||
const rejected = legacy?.rejected ?? 0;
|
||||
return count > 0 || rejected > 0
|
||||
? {
|
||||
preview: [
|
||||
`- Reef peer trust: config -> plugin state (${count} peer(s), ${rejected} invalid)`,
|
||||
],
|
||||
}
|
||||
: null;
|
||||
},
|
||||
async migrateLegacyState({ config, context }) {
|
||||
const legacy = inspectLegacyReefFriends(config);
|
||||
if (!legacy) {
|
||||
return { changes: [], warnings: [] };
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
if (legacy.rejected > 0) {
|
||||
warnings.push(
|
||||
`Skipped ${legacy.rejected} invalid Reef peer trust row(s); left legacy friends config in place`,
|
||||
);
|
||||
}
|
||||
if (!legacy.config) {
|
||||
if (legacy.total > 0) {
|
||||
warnings.push(
|
||||
"Skipped Reef peer trust migration because channels.reef needs a valid handle and canonical config; left legacy friends config in place",
|
||||
);
|
||||
}
|
||||
return { changes: [], warnings };
|
||||
}
|
||||
const reefConfig = legacy.config;
|
||||
if (legacy.friends.size === 0) {
|
||||
return { changes: [], warnings };
|
||||
}
|
||||
const store = context.openPluginStateKeyedStore<ReefPeerStateSnapshot>({
|
||||
namespace: REEF_TRUST_STORE_NAMESPACE,
|
||||
maxEntries: REEF_TRUST_STORE_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
const markerStore = context.openPluginStateKeyedStore<ReefConfigImportMarker>({
|
||||
namespace: REEF_CONFIG_IMPORT_NAMESPACE,
|
||||
maxEntries: REEF_TRUST_STORE_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
});
|
||||
const existingEntries = await store.entries();
|
||||
const existingKeys = new Set(existingEntries.map((entry) => entry.key));
|
||||
const markerEntries = await markerStore.entries();
|
||||
const markedKeys = new Set(markerEntries.map((entry) => entry.key));
|
||||
const pendingKeys = [...legacy.friends.keys()]
|
||||
.map((peer) => resolveReefTrustStoreKey(reefConfig, peer))
|
||||
.filter((key) => !markedKeys.has(key));
|
||||
const missingTrust = pendingKeys.filter((key) => !existingKeys.has(key));
|
||||
const availableTrust = Math.max(0, REEF_TRUST_STORE_MAX_ENTRIES - existingEntries.length);
|
||||
const availableMarkers = Math.max(0, REEF_TRUST_STORE_MAX_ENTRIES - markerEntries.length);
|
||||
if (missingTrust.length > availableTrust || pendingKeys.length > availableMarkers) {
|
||||
warnings.push(
|
||||
`Skipped Reef peer trust migration because plugin state has room for ${availableTrust} of ${missingTrust.length} trust row(s) and ${availableMarkers} of ${pendingKeys.length} import marker(s); left legacy friends config in place`,
|
||||
);
|
||||
return { changes: [], warnings };
|
||||
}
|
||||
let imported = 0;
|
||||
let alreadyPresent = 0;
|
||||
for (const [peer, trust] of legacy.friends) {
|
||||
const key = resolveReefTrustStoreKey(reefConfig, peer);
|
||||
if (markedKeys.has(key)) {
|
||||
continue;
|
||||
}
|
||||
const inserted = await store.registerIfAbsent(key, {
|
||||
revision: 1,
|
||||
trust: { ...trust, approvedAt: 0 },
|
||||
});
|
||||
if (inserted) {
|
||||
imported++;
|
||||
} else {
|
||||
alreadyPresent++;
|
||||
}
|
||||
await markerStore.registerIfAbsent(key, { version: 1, importedAt: Date.now() });
|
||||
markedKeys.add(key);
|
||||
}
|
||||
if (imported === 0 && alreadyPresent === 0) {
|
||||
return { changes: [], warnings };
|
||||
}
|
||||
return {
|
||||
changes: [
|
||||
`Migrated Reef peer trust -> plugin state (${imported} imported, ${alreadyPresent} already present)`,
|
||||
],
|
||||
warnings,
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -39,8 +39,11 @@ describe("Reef configuration boundary", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects the retired config-backed friendship and allowlist fields", () => {
|
||||
for (const retired of [{ friends: {} }, { allowFrom: [] }, { dmPolicy: "pairing" }]) {
|
||||
it("accepts legacy trust snapshots but rejects retired policy fields", () => {
|
||||
expect(ReefChannelConfigSchema.safeParse({ friends: { peer: { legacy: true } } }).success).toBe(
|
||||
true,
|
||||
);
|
||||
for (const retired of [{ allowFrom: [] }, { dmPolicy: "pairing" }]) {
|
||||
expect(ReefChannelConfigSchema.safeParse(retired).success).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -28,6 +28,8 @@ export const ReefChannelConfigSchema = z
|
||||
.optional(),
|
||||
stateDir: z.string().min(1).optional(),
|
||||
requestPolicy: z.enum(["code-only", "friends-of-friends", "open"]).default("code-only"),
|
||||
// Upgrade-only snapshot. Runtime trust is SQLite-backed; doctor imports valid rows.
|
||||
friends: z.unknown().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "./friend-types.js";
|
||||
import type { RelayFriend } from "./types.js";
|
||||
|
||||
const MAX_TRUSTED_PEERS = 4_096;
|
||||
export const REEF_TRUST_STORE_MAX_ENTRIES = 4_096;
|
||||
export const REEF_TRUST_STORE_NAMESPACE = "peer-state";
|
||||
const REEF_PAIRING_APPROVAL_PREFIX = "reef-approval-v1:";
|
||||
const SHA256_HEX_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const ReefOutboundRequestSchema = z.record(z.uuid(), z.number().int().nonnegative());
|
||||
@@ -39,7 +40,7 @@ function requirePeer(raw: string): string {
|
||||
return peer;
|
||||
}
|
||||
|
||||
function resolveIdentityScope(config: ReefChannelConfig): string {
|
||||
function resolveReefIdentityScope(config: ReefChannelConfig): string {
|
||||
if (!config.handle) {
|
||||
throw new Error("Reef handle is required before opening peer trust state");
|
||||
}
|
||||
@@ -50,6 +51,10 @@ function resolveIdentityScope(config: ReefChannelConfig): string {
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
export function resolveReefTrustStoreKey(config: ReefChannelConfig, peer: string): string {
|
||||
return `${resolveReefIdentityScope(config)}:${requirePeer(peer)}`;
|
||||
}
|
||||
|
||||
function resolvePairingKeyDigest(friend: RelayFriend, trustRevision: number): string {
|
||||
return createHash("sha256")
|
||||
.update(
|
||||
@@ -65,8 +70,8 @@ export function isReefPairingApprovalToken(raw: string): boolean {
|
||||
function openStores(openStore: PluginRuntime["state"]["openSyncKeyedStore"]): ReefTrustStores {
|
||||
return {
|
||||
peers: openStore<ReefPeerStateSnapshot>({
|
||||
namespace: "peer-state",
|
||||
maxEntries: MAX_TRUSTED_PEERS,
|
||||
namespace: REEF_TRUST_STORE_NAMESPACE,
|
||||
maxEntries: REEF_TRUST_STORE_MAX_ENTRIES,
|
||||
overflowPolicy: "reject-new",
|
||||
}),
|
||||
};
|
||||
@@ -81,7 +86,7 @@ export class ReefTrustStore {
|
||||
readonly stores: ReefTrustStores,
|
||||
config: ReefChannelConfig,
|
||||
) {
|
||||
this.#identityScope = resolveIdentityScope(config);
|
||||
this.#identityScope = resolveReefIdentityScope(config);
|
||||
this.#prefix = `${this.#identityScope}:`;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user