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:
Peter Steinberger
2026-07-15 11:37:32 -07:00
committed by GitHub
parent f4f5bb15d4
commit ff4d854167
6 changed files with 423 additions and 15 deletions

View 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();
});
});

View 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,
};
},
},
];

View File

@@ -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);
}
});

View File

@@ -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();

View File

@@ -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