From 33c246dbbaaa8faa00ef5bb362d9db646db5a058 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 31 May 2026 18:09:27 +0100 Subject: [PATCH] refactor: move plugin state slices to sqlite * refactor: move plugin state slices to sqlite * fix: keep legacy plugin state migration out of runtime * fix: add doctor migrations for plugin sqlite state * fix: preserve teams feedback learning migration keys * fix: merge teams legacy feedback learnings * fix: guard doctor imports against plugin state caps * fix: leave lossy teams learning filenames unmigrated * fix: preserve teams feedback learning scope * fix: load plugin doctor contracts from package dist * fix: satisfy plugin state migration gates --- .../active-memory/doctor-contract-api.test.ts | 99 ++++++ .../active-memory/doctor-contract-api.ts | 146 +++++++++ extensions/active-memory/index.test.ts | 11 + extensions/active-memory/index.ts | 121 ++----- .../msteams/doctor-contract-api.test.ts | 139 ++++++++ extensions/msteams/doctor-contract-api.ts | 256 +++++++++++++++ .../msteams/src/feedback-reflection-store.ts | 79 ++--- .../msteams/src/feedback-reflection.test.ts | 76 +++-- extensions/nostr/doctor-contract-api.test.ts | 104 ++++++ extensions/nostr/doctor-contract-api.ts | 296 ++++++++++++++++++ .../nostr/src/nostr-state-store.test.ts | 88 +----- extensions/nostr/src/nostr-state-store.ts | 125 ++------ .../phone-control/doctor-contract-api.test.ts | 91 ++++++ .../phone-control/doctor-contract-api.ts | 162 ++++++++++ extensions/phone-control/index.test.ts | 16 +- extensions/phone-control/index.ts | 109 ++----- ...octor-contract-registry.load-paths.test.ts | 69 ++++ src/plugins/doctor-contract-registry.ts | 18 +- test/plugin-npm-runtime-build.test.ts | 16 + 19 files changed, 1576 insertions(+), 445 deletions(-) create mode 100644 extensions/active-memory/doctor-contract-api.test.ts create mode 100644 extensions/active-memory/doctor-contract-api.ts create mode 100644 extensions/msteams/doctor-contract-api.test.ts create mode 100644 extensions/msteams/doctor-contract-api.ts create mode 100644 extensions/nostr/doctor-contract-api.test.ts create mode 100644 extensions/nostr/doctor-contract-api.ts create mode 100644 extensions/phone-control/doctor-contract-api.test.ts create mode 100644 extensions/phone-control/doctor-contract-api.ts diff --git a/extensions/active-memory/doctor-contract-api.test.ts b/extensions/active-memory/doctor-contract-api.test.ts new file mode 100644 index 00000000000..b69465c1375 --- /dev/null +++ b/extensions/active-memory/doctor-contract-api.test.ts @@ -0,0 +1,99 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +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 } from "vitest"; +import { stateMigrations } from "./doctor-contract-api.js"; + +function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext { + return { + openPluginStateKeyedStore(options: OpenKeyedStoreOptions) { + return createPluginStateKeyedStoreForTests("active-memory", { + ...options, + env: options.env ?? env, + }); + }, + }; +} + +describe("active-memory doctor state migration", () => { + let stateDir = ""; + let env: NodeJS.ProcessEnv; + + beforeEach(async () => { + resetPluginStateStoreForTests(); + stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-doctor-")); + env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + }); + + afterEach(async () => { + await fs.rm(stateDir, { recursive: true, force: true }); + }); + + it("imports legacy session opt-outs into plugin state", async () => { + const sourcePath = path.join(stateDir, "plugins", "active-memory", "session-toggles.json"); + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile( + sourcePath, + JSON.stringify({ + sessions: { + "telegram:dm:123": { disabled: true, updatedAt: 1700 }, + "telegram:dm:456": { disabled: false, updatedAt: 1701 }, + }, + }), + ); + + const migration = stateMigrations[0]; + await expect( + migration.detectLegacyState({ + config: {}, + env, + stateDir, + oauthDir: path.join(stateDir, "oauth"), + context: createDoctorContext(env), + }), + ).resolves.toMatchObject({ + preview: [expect.stringContaining("1 entry")], + }); + + const result = await migration.migrateLegacyState({ + config: {}, + env, + stateDir, + oauthDir: path.join(stateDir, "oauth"), + context: createDoctorContext(env), + }); + + expect(result.warnings).toEqual([]); + expect(result.changes).toEqual([ + expect.stringContaining("Migrated 1 Active Memory session toggle entry"), + expect.stringContaining("Archived Active Memory session toggles legacy source"), + ]); + await expect(fs.access(sourcePath)).rejects.toThrow(); + await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined(); + + const entries = await createDoctorContext(env) + .openPluginStateKeyedStore({ + namespace: "session-toggles", + maxEntries: 10_000, + }) + .entries(); + expect(entries).toMatchObject([ + { + key: expect.any(String), + value: { + sessionKey: "telegram:dm:123", + disabled: true, + updatedAt: 1700, + }, + }, + ]); + }); +}); diff --git a/extensions/active-memory/doctor-contract-api.ts b/extensions/active-memory/doctor-contract-api.ts new file mode 100644 index 00000000000..ff7d5dd301c --- /dev/null +++ b/extensions/active-memory/doctor-contract-api.ts @@ -0,0 +1,146 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor"; + +type ActiveMemoryToggleEntry = { + sessionKey: string; + disabled: boolean; + updatedAt: number; +}; + +const TOGGLE_STATE_FILE = "session-toggles.json"; +const SESSION_TOGGLES_NAMESPACE = "session-toggles"; +const MAX_TOGGLE_ENTRIES = 10_000; + +function resolveToggleStatePath(stateDir: string): string { + return path.join(stateDir, "plugins", "active-memory", TOGGLE_STATE_FILE); +} + +function activeMemoryToggleKey(sessionKey: string): string { + return crypto.createHash("sha256").update(sessionKey, "utf8").digest("hex"); +} + +async function fileExists(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + return stat.isFile(); + } catch { + return false; + } +} + +async function readLegacyToggleEntries(filePath: string): Promise { + try { + const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object") { + return []; + } + const sessions = (parsed as { sessions?: unknown }).sessions; + if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) { + return []; + } + const entries: ActiveMemoryToggleEntry[] = []; + for (const [sessionKey, value] of Object.entries(sessions)) { + if (!sessionKey.trim() || !value || typeof value !== "object" || Array.isArray(value)) { + continue; + } + if ((value as { disabled?: unknown }).disabled !== true) { + continue; + } + const updatedAt = + typeof (value as { updatedAt?: unknown }).updatedAt === "number" + ? (value as { updatedAt: number }).updatedAt + : Date.now(); + entries.push({ sessionKey, disabled: true, updatedAt }); + } + return entries; + } catch { + return []; + } +} + +async function archiveLegacySource(params: { + filePath: string; + label: string; + changes: string[]; + warnings: string[]; +}): Promise { + const archivedPath = `${params.filePath}.migrated`; + if (await fileExists(archivedPath)) { + params.warnings.push( + `Left migrated ${params.label} source in place because ${archivedPath} already exists`, + ); + return; + } + try { + await fs.rename(params.filePath, archivedPath); + params.changes.push(`Archived ${params.label} legacy source -> ${archivedPath}`); + } catch (err) { + params.warnings.push(`Failed archiving ${params.label} legacy source: ${String(err)}`); + } +} + +export const stateMigrations: PluginDoctorStateMigration[] = [ + { + id: "active-memory-session-toggles-json-to-plugin-state", + label: "Active Memory session toggles", + async detectLegacyState(params) { + const filePath = resolveToggleStatePath(params.stateDir); + const entries = await readLegacyToggleEntries(filePath); + if (entries.length === 0) { + return null; + } + return { + preview: [ + `- Active Memory session toggles: ${entries.length} ${entries.length === 1 ? "entry" : "entries"} -> plugin state (${SESSION_TOGGLES_NAMESPACE})`, + ], + }; + }, + async migrateLegacyState(params) { + const changes: string[] = []; + const warnings: string[] = []; + const filePath = resolveToggleStatePath(params.stateDir); + const entries = await readLegacyToggleEntries(filePath); + if (entries.length === 0) { + return { changes, warnings }; + } + const store = params.context.openPluginStateKeyedStore({ + namespace: SESSION_TOGGLES_NAMESPACE, + maxEntries: MAX_TOGGLE_ENTRIES, + }); + const existingKeys = new Set((await store.entries()).map((entry) => entry.key)); + const missingEntries = entries.filter( + (entry) => !existingKeys.has(activeMemoryToggleKey(entry.sessionKey)), + ); + if (missingEntries.length > MAX_TOGGLE_ENTRIES - existingKeys.size) { + warnings.push( + `Skipped Active Memory session toggle migration because plugin state has room for ${MAX_TOGGLE_ENTRIES - existingKeys.size} of ${missingEntries.length} missing entries; left legacy source in place`, + ); + return { changes, warnings }; + } + let imported = 0; + for (const entry of entries) { + const key = activeMemoryToggleKey(entry.sessionKey); + if (existingKeys.has(key)) { + continue; + } + await store.register(key, entry); + existingKeys.add(key); + imported++; + } + if (imported > 0) { + changes.push( + `Migrated ${imported} Active Memory session toggle ${imported === 1 ? "entry" : "entries"} -> plugin state`, + ); + } + await archiveLegacySource({ + filePath, + label: "Active Memory session toggles", + changes, + warnings, + }); + return { changes, warnings }; + }, + }, +]; diff --git a/extensions/active-memory/index.test.ts b/extensions/active-memory/index.test.ts index 6eb6e5ec31f..51b7687b538 100644 --- a/extensions/active-memory/index.test.ts +++ b/extensions/active-memory/index.test.ts @@ -2,6 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; +import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; +import { + createPluginStateKeyedStoreForTests, + resetPluginStateStoreForTests, +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import plugin, { testing } from "./index.js"; @@ -156,6 +161,11 @@ describe("active-memory plugin", () => { }, state: { resolveStateDir: () => stateDir, + openKeyedStore: (options: OpenKeyedStoreOptions) => + createPluginStateKeyedStoreForTests("active-memory", { + ...options, + env: { ...process.env, OPENCLAW_STATE_DIR: stateDir }, + }), }, config: { current: () => configFile, @@ -309,6 +319,7 @@ describe("active-memory plugin", () => { beforeEach(async () => { vi.clearAllMocks(); + resetPluginStateStoreForTests(); runEmbeddedAgent.mockReset(); stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-active-memory-test-")); configFile = { diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index 4fef112d89e..4ec5f25bebc 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -24,7 +24,7 @@ import { } from "openclaw/plugin-sdk/plugin-config-runtime"; import { definePluginEntry, type OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { parseAgentSessionKey, parseThreadSessionSuffix } from "openclaw/plugin-sdk/routing"; -import { isPathInside, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; +import { isPathInside } from "openclaw/plugin-sdk/security-runtime"; import { asOptionalRecord as asRecord, normalizeOptionalString, @@ -88,7 +88,6 @@ const ACTIVE_MEMORY_RESERVED_TOOLS_ALLOW = new Set([ "web_search", "write", ]); -const TOGGLE_STATE_FILE = "session-toggles.json"; const DEFAULT_PARTIAL_TRANSCRIPT_MAX_CHARS = 32_000; const DEFAULT_TRANSCRIPT_READ_MAX_LINES = 2_000; const DEFAULT_TRANSCRIPT_READ_MAX_BYTES = 50 * 1024 * 1024; @@ -287,44 +286,16 @@ type CachedActiveRecallResult = { type ActiveMemoryChatType = "direct" | "group" | "channel" | "explicit"; -type ActiveMemoryToggleStore = { - sessions?: Record; +type ActiveMemoryToggleEntry = { + sessionKey: string; + disabled: true; + updatedAt: number; }; - -type AsyncLock = (task: () => Promise) => Promise; - -const toggleStoreLocks = new Map(); let lastActiveRecallCacheSweepAt = 0; let minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS; let setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS; let timeoutPartialDataGraceMs = TIMEOUT_PARTIAL_DATA_GRACE_MS; -function createAsyncLock(): AsyncLock { - let lock: Promise = Promise.resolve(); - return async function withLock(task: () => Promise): Promise { - const previous = lock; - let release: (() => void) | undefined; - lock = new Promise((resolve) => { - release = resolve; - }); - await previous; - try { - return await task(); - } finally { - release?.(); - } - }; -} - -function withToggleStoreLock(statePath: string, task: () => Promise): Promise { - let withLock = toggleStoreLocks.get(statePath); - if (!withLock) { - withLock = createAsyncLock(); - toggleStoreLocks.set(statePath, withLock); - } - return withLock(task); -} - type ActiveMemoryThinkingLevel = | "off" | "minimal" @@ -696,54 +667,14 @@ function resolveRecallRunChannelContext(params: { } } -function resolveToggleStatePath(api: OpenClawPluginApi): string { - return path.join( - api.runtime.state.resolveStateDir(), - "plugins", - "active-memory", - TOGGLE_STATE_FILE, - ); +function activeMemoryToggleKey(sessionKey: string): string { + return crypto.createHash("sha256").update(sessionKey, "utf8").digest("hex"); } -async function readToggleStore(statePath: string): Promise { - try { - const raw = await fs.readFile(statePath, "utf8"); - const parsed = JSON.parse(raw) as unknown; - if (!parsed || typeof parsed !== "object") { - return {}; - } - const sessions = (parsed as { sessions?: unknown }).sessions; - if (!sessions || typeof sessions !== "object" || Array.isArray(sessions)) { - return {}; - } - const nextSessions: NonNullable = {}; - for (const [sessionKey, value] of Object.entries(sessions)) { - if (!sessionKey.trim() || !value || typeof value !== "object" || Array.isArray(value)) { - continue; - } - const disabled = (value as { disabled?: unknown }).disabled === true; - const updatedAt = - typeof (value as { updatedAt?: unknown }).updatedAt === "number" - ? (value as { updatedAt: number }).updatedAt - : undefined; - if (disabled) { - nextSessions[sessionKey] = { disabled, updatedAt }; - } - } - return Object.keys(nextSessions).length > 0 ? { sessions: nextSessions } : {}; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return {}; - } - return {}; - } -} - -async function writeToggleStore(statePath: string, store: ActiveMemoryToggleStore): Promise { - await replaceFileAtomic({ - filePath: statePath, - content: `${JSON.stringify(store, null, 2)}\n`, - tempPrefix: ".active-memory", +function openActiveMemoryToggleStore(api: OpenClawPluginApi) { + return api.runtime.state.openKeyedStore({ + namespace: "session-toggles", + maxEntries: 10_000, }); } @@ -756,8 +687,13 @@ async function isSessionActiveMemoryDisabled(params: { return false; } try { - const store = await readToggleStore(resolveToggleStatePath(params.api)); - return store.sessions?.[sessionKey]?.disabled === true; + const store = openActiveMemoryToggleStore(params.api); + const key = activeMemoryToggleKey(sessionKey); + const stored = await store.lookup(key); + if (stored?.disabled === true) { + return true; + } + return false; } catch (error) { params.api.logger.debug?.( `active-memory: failed to read session toggle (${error instanceof Error ? error.message : String(error)})`, @@ -771,17 +707,16 @@ async function setSessionActiveMemoryDisabled(params: { sessionKey: string; disabled: boolean; }): Promise { - const statePath = resolveToggleStatePath(params.api); - await withToggleStoreLock(statePath, async () => { - const store = await readToggleStore(statePath); - const sessions = { ...store.sessions }; - if (params.disabled) { - sessions[params.sessionKey] = { disabled: true, updatedAt: Date.now() }; - } else { - delete sessions[params.sessionKey]; - } - await writeToggleStore(statePath, Object.keys(sessions).length > 0 ? { sessions } : {}); - }); + const store = openActiveMemoryToggleStore(params.api); + if (params.disabled) { + await store.register(activeMemoryToggleKey(params.sessionKey), { + sessionKey: params.sessionKey, + disabled: true, + updatedAt: Date.now(), + }); + } else { + await store.delete(activeMemoryToggleKey(params.sessionKey)); + } } function resolveCommandSessionKey(params: { diff --git a/extensions/msteams/doctor-contract-api.test.ts b/extensions/msteams/doctor-contract-api.test.ts new file mode 100644 index 00000000000..bf5f88f76ec --- /dev/null +++ b/extensions/msteams/doctor-contract-api.test.ts @@ -0,0 +1,139 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +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 } from "vitest"; +import { stateMigrations } from "./doctor-contract-api.js"; + +function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext { + return { + openPluginStateKeyedStore(options: OpenKeyedStoreOptions) { + return createPluginStateKeyedStoreForTests("msteams", { + ...options, + env: options.env ?? env, + }); + }, + }; +} + +function encodeSessionKey(sessionKey: string): string { + return Buffer.from(sessionKey, "utf8").toString("base64url"); +} + +function learningStoreKey(storePath: string, sessionKey: string): string { + return createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex"); +} + +describe("msteams doctor state migration", () => { + let stateDir = ""; + let env: NodeJS.ProcessEnv; + + beforeEach(async () => { + resetPluginStateStoreForTests(); + stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-msteams-doctor-")); + env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + }); + + afterEach(async () => { + await fs.rm(stateDir, { recursive: true, force: true }); + }); + + it("imports legacy feedback learnings into plugin state", async () => { + const agentStoreTemplate = path.join(stateDir, "agents", "{agentId}", "sessions"); + const mainStorePath = path.join(stateDir, "agents", "main", "sessions"); + const workStorePath = path.join(stateDir, "agents", "work", "sessions"); + const encodedSessionKey = "msteams:user1"; + const encodedSourcePath = path.join( + mainStorePath, + `${encodeSessionKey(encodedSessionKey)}.learnings.json`, + ); + const sanitizedSessionKey = "msteams:channel:19:abc@thread.tacv2"; + const sanitizedSourcePath = path.join( + workStorePath, + "msteams_channel_19_abc_thread_tacv2.learnings.json", + ); + await fs.mkdir(mainStorePath, { recursive: true }); + await fs.mkdir(workStorePath, { recursive: true }); + await fs.writeFile( + path.join(workStorePath, "sessions.json"), + JSON.stringify({ sessions: { [sanitizedSessionKey]: {} } }), + ); + await fs.writeFile(encodedSourcePath, JSON.stringify(["Be concise", "Use examples"])); + await fs.writeFile(sanitizedSourcePath, JSON.stringify(["Prefer cards for channel feedback"])); + + const migration = stateMigrations[0]; + const context = createDoctorContext(env); + await context + .openPluginStateKeyedStore({ + namespace: "feedback-learnings", + maxEntries: 10_000, + }) + .register(learningStoreKey(mainStorePath, encodedSessionKey), { + sessionKey: encodedSessionKey, + learnings: ["Use examples", "New runtime note"], + updatedAt: 1900, + }); + + await expect( + migration.detectLegacyState({ + config: { + session: { store: agentStoreTemplate }, + agents: { list: [{ id: "work" }] }, + }, + env, + stateDir, + oauthDir: path.join(stateDir, "oauth"), + context, + }), + ).resolves.toMatchObject({ + preview: [expect.stringContaining("2 files")], + }); + + const result = await migration.migrateLegacyState({ + config: { + session: { store: agentStoreTemplate }, + agents: { list: [{ id: "work" }] }, + }, + env, + stateDir, + oauthDir: path.join(stateDir, "oauth"), + context, + }); + + expect(result.changes).toEqual([ + expect.stringContaining("Migrated 2 Microsoft Teams feedback-learning entries"), + expect.stringContaining("Archived Microsoft Teams feedback-learning legacy source"), + expect.stringContaining("Archived Microsoft Teams feedback-learning legacy source"), + ]); + expect(result.warnings).toEqual([]); + await expect(fs.access(encodedSourcePath)).rejects.toThrow(); + await expect(fs.access(sanitizedSourcePath)).rejects.toThrow(); + await expect(fs.access(`${encodedSourcePath}.migrated`)).resolves.toBeUndefined(); + await expect(fs.access(`${sanitizedSourcePath}.migrated`)).resolves.toBeUndefined(); + + const store = context.openPluginStateKeyedStore({ + namespace: "feedback-learnings", + maxEntries: 10_000, + }); + await expect( + store.lookup(learningStoreKey(mainStorePath, encodedSessionKey)), + ).resolves.toMatchObject({ + sessionKey: encodedSessionKey, + learnings: ["Be concise", "Use examples", "New runtime note"], + }); + await expect( + store.lookup(learningStoreKey(workStorePath, sanitizedSessionKey)), + ).resolves.toMatchObject({ + sessionKey: sanitizedSessionKey, + learnings: ["Prefer cards for channel feedback"], + }); + }); +}); diff --git a/extensions/msteams/doctor-contract-api.ts b/extensions/msteams/doctor-contract-api.ts new file mode 100644 index 00000000000..5026a581438 --- /dev/null +++ b/extensions/msteams/doctor-contract-api.ts @@ -0,0 +1,256 @@ +import crypto from "node:crypto"; +import type { Dirent } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor"; +import { resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime"; + +type FeedbackLearningEntry = { + sessionKey: string; + learnings: string[]; + updatedAt: number; +}; + +const LEARNINGS_NAMESPACE = "feedback-learnings"; +const MAX_LEARNING_ENTRIES = 10_000; + +function encodeSessionKey(sessionKey: string): string { + return Buffer.from(sessionKey, "utf8").toString("base64url"); +} + +function learningStoreKey(storePath: string, sessionKey: string): string { + return crypto.createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex"); +} + +function decodeSessionKey(fileStem: string): string | null { + try { + const decoded = Buffer.from(fileStem, "base64url").toString("utf8"); + return encodeSessionKey(decoded) === fileStem && decoded.trim() ? decoded : null; + } catch { + return null; + } +} + +function resolveLearningSessionKey(fileStem: string): string | null { + return decodeSessionKey(fileStem); +} + +function legacySanitizeSessionKey(sessionKey: string): string { + return sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_"); +} + +async function listKnownSessionKeys(storePath: string): Promise { + const candidates = [storePath, path.join(storePath, "sessions.json")]; + for (const candidate of candidates) { + try { + const parsed = JSON.parse(await fs.readFile(candidate, "utf8")) as unknown; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + continue; + } + const sessions = + (parsed as { sessions?: unknown }).sessions && + typeof (parsed as { sessions?: unknown }).sessions === "object" && + !Array.isArray((parsed as { sessions?: unknown }).sessions) + ? (parsed as { sessions: Record }).sessions + : (parsed as Record); + return Object.keys(sessions).filter((key) => key.trim()); + } catch { + // Try the next known session index shape/location. + } + } + return []; +} + +function resolveLegacySanitizedSessionKey( + fileStem: string, + knownSessionKeys: string[], +): string | null { + const matches = knownSessionKeys.filter( + (sessionKey) => legacySanitizeSessionKey(sessionKey) === fileStem, + ); + return matches.length === 1 ? matches[0] : null; +} + +function listAgentIds(config: { agents?: { list?: Array<{ id?: unknown }> } }): string[] { + const ids = new Set(["main"]); + for (const agent of config.agents?.list ?? []) { + if (typeof agent.id === "string" && agent.id.trim()) { + ids.add(agent.id.trim()); + } + } + return [...ids]; +} + +function listCandidateStorePaths(params: { + config: Parameters[0]["config"]; + env: NodeJS.ProcessEnv; +}): string[] { + const paths = new Set(); + paths.add(resolveStorePath(params.config.session?.store, { env: params.env })); + for (const agentId of listAgentIds(params.config)) { + paths.add(resolveStorePath(params.config.session?.store, { agentId, env: params.env })); + } + return [...paths]; +} + +async function fileExists(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + return stat.isFile(); + } catch { + return false; + } +} + +async function listLegacyLearningFiles( + storePath: string, +): Promise< + Array<{ storePath: string; sessionKey: string | null; filePath: string; learnings: string[] }> +> { + let entries: Dirent[] = []; + try { + entries = await fs.readdir(storePath, { withFileTypes: true }); + } catch { + return []; + } + const suffix = ".learnings.json"; + const knownSessionKeys = await listKnownSessionKeys(storePath); + const files: Array<{ + storePath: string; + sessionKey: string | null; + filePath: string; + learnings: string[]; + }> = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(suffix)) { + continue; + } + const fileStem = entry.name.slice(0, -suffix.length); + const sessionKey = + resolveLearningSessionKey(fileStem) ?? + resolveLegacySanitizedSessionKey(fileStem, knownSessionKeys); + const filePath = path.join(storePath, entry.name); + try { + const parsed = JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; + if (Array.isArray(parsed)) { + const learnings = parsed.filter((item): item is string => typeof item === "string"); + if (learnings.length > 0) { + files.push({ storePath, sessionKey, filePath, learnings: learnings.slice(-10) }); + } + } + } catch { + // Malformed legacy feedback notes are ignored by migration. + } + } + return files; +} + +async function archiveLegacySource(params: { + filePath: string; + changes: string[]; + warnings: string[]; +}): Promise { + const archivedPath = `${params.filePath}.migrated`; + if (await fileExists(archivedPath)) { + params.warnings.push( + `Left migrated Microsoft Teams feedback-learning source in place because ${archivedPath} already exists`, + ); + return; + } + try { + await fs.rename(params.filePath, archivedPath); + params.changes.push( + `Archived Microsoft Teams feedback-learning legacy source -> ${archivedPath}`, + ); + } catch (err) { + params.warnings.push( + `Failed archiving Microsoft Teams feedback-learning legacy source: ${String(err)}`, + ); + } +} + +function mergeLearnings(legacy: string[], existing?: FeedbackLearningEntry): string[] { + const seen = new Set(); + const merged: string[] = []; + for (const learning of [...legacy, ...(existing?.learnings ?? [])]) { + if (seen.has(learning)) { + continue; + } + seen.add(learning); + merged.push(learning); + } + return merged.slice(-10); +} + +export const stateMigrations: PluginDoctorStateMigration[] = [ + { + id: "msteams-feedback-learnings-json-to-plugin-state", + label: "Microsoft Teams feedback learnings", + async detectLegacyState(params) { + const files = ( + await Promise.all( + listCandidateStorePaths(params).map((storePath) => listLegacyLearningFiles(storePath)), + ) + ).flat(); + if (files.length === 0) { + return null; + } + return { + preview: [ + `- Microsoft Teams feedback learnings: ${files.length} ${files.length === 1 ? "file" : "files"} -> plugin state (${LEARNINGS_NAMESPACE})`, + ], + }; + }, + async migrateLegacyState(params) { + const changes: string[] = []; + const warnings: string[] = []; + const files = ( + await Promise.all( + listCandidateStorePaths(params).map((storePath) => listLegacyLearningFiles(storePath)), + ) + ).flat(); + const store = params.context.openPluginStateKeyedStore({ + namespace: LEARNINGS_NAMESPACE, + maxEntries: MAX_LEARNING_ENTRIES, + }); + const existingEntries = await store.entries(); + const existingKeys = new Set(existingEntries.map((entry) => entry.key)); + const importableFiles = files.filter((file) => file.sessionKey); + const missingKeys = new Set( + importableFiles + .map((file) => learningStoreKey(file.storePath, file.sessionKey ?? "")) + .filter((key) => !existingKeys.has(key)), + ); + if (missingKeys.size > MAX_LEARNING_ENTRIES - existingKeys.size) { + warnings.push( + `Skipped Microsoft Teams feedback-learning migration because plugin state has room for ${MAX_LEARNING_ENTRIES - existingKeys.size} of ${missingKeys.size} missing entries; left legacy sources in place`, + ); + return { changes, warnings }; + } + let imported = 0; + for (const file of files) { + if (!file.sessionKey) { + warnings.push( + `Left Microsoft Teams feedback-learning source in place because its legacy filename cannot be mapped to a session key: ${file.filePath}`, + ); + continue; + } + const key = learningStoreKey(file.storePath, file.sessionKey); + const existing = await store.lookup(key); + await store.register(key, { + sessionKey: existing?.sessionKey ?? file.sessionKey, + learnings: mergeLearnings(file.learnings, existing), + updatedAt: Date.now(), + }); + imported++; + await archiveLegacySource({ filePath: file.filePath, changes, warnings }); + } + if (imported > 0) { + changes.unshift( + `Migrated ${imported} Microsoft Teams feedback-learning ${imported === 1 ? "entry" : "entries"} -> plugin state`, + ); + } + return { changes, warnings }; + }, + }, +]; diff --git a/extensions/msteams/src/feedback-reflection-store.ts b/extensions/msteams/src/feedback-reflection-store.ts index f32929947b2..9ad93426397 100644 --- a/extensions/msteams/src/feedback-reflection-store.ts +++ b/extensions/msteams/src/feedback-reflection-store.ts @@ -1,5 +1,5 @@ -import fs from "node:fs/promises"; -import { writeJsonFileAtomically } from "openclaw/plugin-sdk/json-store"; +import crypto from "node:crypto"; +import { getMSTeamsRuntime } from "./runtime.js"; /** Default cooldown between reflections per session (5 minutes). */ export const DEFAULT_COOLDOWN_MS = 300_000; @@ -9,33 +9,24 @@ const lastReflectionBySession = new Map(); /** Maximum cooldown entries before pruning expired ones. */ const MAX_COOLDOWN_ENTRIES = 500; +const LEARNINGS_NAMESPACE = "feedback-learnings"; +const MAX_LEARNING_ENTRIES = 10_000; -function legacySanitizeSessionKey(sessionKey: string): string { - return sessionKey.replace(/[^a-zA-Z0-9_-]/g, "_"); +type FeedbackLearningEntry = { + sessionKey: string; + learnings: string[]; + updatedAt: number; +}; + +function learningStoreKey(storePath: string, sessionKey: string): string { + return crypto.createHash("sha256").update(`${storePath}\0${sessionKey}`, "utf8").digest("hex"); } -function encodeSessionKey(sessionKey: string): string { - return Buffer.from(sessionKey, "utf8").toString("base64url"); -} - -function resolveLearningsFilePath(storePath: string, sessionKey: string): string { - return `${storePath}/${encodeSessionKey(sessionKey)}.learnings.json`; -} - -function resolveLegacyLearningsFilePath(storePath: string, sessionKey: string): string { - return `${storePath}/${legacySanitizeSessionKey(sessionKey)}.learnings.json`; -} - -async function readLearningsFile( - filePath: string, -): Promise<{ exists: boolean; learnings: string[] }> { - try { - const content = await fs.readFile(filePath, "utf-8"); - const parsed = JSON.parse(content); - return { exists: true, learnings: Array.isArray(parsed) ? parsed : [] }; - } catch { - return { exists: false, learnings: [] }; - } +function openLearningStore() { + return getMSTeamsRuntime().state.openKeyedStore({ + namespace: LEARNINGS_NAMESPACE, + maxEntries: MAX_LEARNING_ENTRIES, + }); } /** Prune expired cooldown entries to prevent unbounded memory growth. */ @@ -72,31 +63,25 @@ export function clearReflectionCooldowns(): void { lastReflectionBySession.clear(); } -/** Store a learning derived from feedback reflection in a session companion file. */ +/** Store a learning derived from feedback reflection. */ export async function storeSessionLearning(params: { storePath: string; sessionKey: string; learning: string; }): Promise { - const learningsFile = resolveLearningsFilePath(params.storePath, params.sessionKey); - const legacyLearningsFile = resolveLegacyLearningsFilePath(params.storePath, params.sessionKey); - const { exists, learnings: existingLearnings } = await readLearningsFile(learningsFile); - const { learnings: legacyLearnings } = - exists || legacyLearningsFile === learningsFile - ? { learnings: [] as string[] } - : await readLearningsFile(legacyLearningsFile); - - let learnings = exists ? existingLearnings : legacyLearnings; - + const store = openLearningStore(); + const key = learningStoreKey(params.storePath, params.sessionKey); + const existing = await store.lookup(key); + let learnings = existing?.learnings ?? []; learnings.push(params.learning); if (learnings.length > 10) { learnings = learnings.slice(-10); } - - await writeJsonFileAtomically(learningsFile, learnings); - if (!exists && legacyLearningsFile !== learningsFile) { - await fs.rm(legacyLearningsFile, { force: true }).catch(() => undefined); - } + await store.register(key, { + sessionKey: params.sessionKey, + learnings, + updatedAt: Date.now(), + }); } /** Load session learnings for injection into extraSystemPrompt. */ @@ -104,10 +89,10 @@ export async function loadSessionLearnings( storePath: string, sessionKey: string, ): Promise { - const learningsFile = resolveLearningsFilePath(storePath, sessionKey); - const { exists, learnings } = await readLearningsFile(learningsFile); - if (exists) { - return learnings; + const key = learningStoreKey(storePath, sessionKey); + const stored = await openLearningStore().lookup(key); + if (stored) { + return stored.learnings; } - return (await readLearningsFile(resolveLegacyLearningsFilePath(storePath, sessionKey))).learnings; + return []; } diff --git a/extensions/msteams/src/feedback-reflection.test.ts b/extensions/msteams/src/feedback-reflection.test.ts index 42d2e67613c..3e8a3711378 100644 --- a/extensions/msteams/src/feedback-reflection.test.ts +++ b/extensions/msteams/src/feedback-reflection.test.ts @@ -1,7 +1,8 @@ -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { storeSessionLearning } from "./feedback-reflection-store.js"; import { buildFeedbackEvent, @@ -12,6 +13,10 @@ import { parseReflectionResponse, recordReflectionTime, } from "./feedback-reflection.js"; +import { setMSTeamsRuntime } from "./runtime.js"; +import { msteamsRuntimeStub } from "./test-support/runtime.js"; + +const previousStateDir = process.env.OPENCLAW_STATE_DIR; describe("buildFeedbackEvent", () => { it("builds a well-formed custom event", () => { @@ -161,7 +166,17 @@ describe("reflection cooldown", () => { describe("loadSessionLearnings", () => { let tmpDir: string; + beforeEach(() => { + resetPluginStateStoreForTests(); + setMSTeamsRuntime(msteamsRuntimeStub); + }); + afterEach(async () => { + if (previousStateDir === undefined) { + delete process.env.OPENCLAW_STATE_DIR; + } else { + process.env.OPENCLAW_STATE_DIR = previousStateDir; + } if (tmpDir) { await rm(tmpDir, { recursive: true, force: true }); } @@ -169,15 +184,24 @@ describe("loadSessionLearnings", () => { it("returns empty array when file doesn't exist", async () => { tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-")); + process.env.OPENCLAW_STATE_DIR = tmpDir; const learnings = await loadSessionLearnings(tmpDir, "nonexistent"); expect(learnings).toStrictEqual([]); }); - it("reads existing learnings", async () => { + it("reads persisted learnings from plugin state", async () => { tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-")); - const safeKey = Buffer.from("msteams:user1", "utf8").toString("base64url"); - const filePath = path.join(tmpDir, `${safeKey}.learnings.json`); - await writeFile(filePath, JSON.stringify(["Be concise", "Use examples"]), "utf-8"); + process.env.OPENCLAW_STATE_DIR = tmpDir; + await storeSessionLearning({ + storePath: tmpDir, + sessionKey: "msteams:user1", + learning: "Be concise", + }); + await storeSessionLearning({ + storePath: tmpDir, + sessionKey: "msteams:user1", + learning: "Use examples", + }); const learnings = await loadSessionLearnings(tmpDir, "msteams:user1"); expect(learnings).toEqual(["Be concise", "Use examples"]); @@ -185,6 +209,7 @@ describe("loadSessionLearnings", () => { it("keeps distinct session keys isolated across the filename persistence boundary", async () => { tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-")); + process.env.OPENCLAW_STATE_DIR = tmpDir; await storeSessionLearning({ storePath: tmpDir, @@ -201,37 +226,28 @@ describe("loadSessionLearnings", () => { await expect(loadSessionLearnings(tmpDir, "msteams/user1")).resolves.toEqual(["Avoid bullets"]); }); - it("reads and migrates legacy sanitized session learning files", async () => { + it("keeps the same session key isolated by store path", async () => { tmpDir = await mkdtemp(path.join(os.tmpdir(), "learnings-test-")); - const legacyFile = path.join(tmpDir, "msteams_user1.learnings.json"); - await writeFile(legacyFile, JSON.stringify(["Legacy learning"]), "utf-8"); - - await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual([ - "Legacy learning", - ]); + process.env.OPENCLAW_STATE_DIR = tmpDir; + const workStorePath = path.join(tmpDir, "work"); + const opsStorePath = path.join(tmpDir, "ops"); await storeSessionLearning({ - storePath: tmpDir, + storePath: workStorePath, sessionKey: "msteams:user1", - learning: "New learning", + learning: "Use bullets", + }); + await storeSessionLearning({ + storePath: opsStorePath, + sessionKey: "msteams:user1", + learning: "Avoid bullets", }); - const migratedFile = path.join( - tmpDir, - `${Buffer.from("msteams:user1", "utf8").toString("base64url")}.learnings.json`, - ); - await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual([ - "Legacy learning", - "New learning", + await expect(loadSessionLearnings(workStorePath, "msteams:user1")).resolves.toEqual([ + "Use bullets", ]); - await expect(rm(legacyFile, { force: false })).rejects.toHaveProperty("code", "ENOENT"); - await expect(loadSessionLearnings(tmpDir, "msteams:user1")).resolves.toEqual([ - "Legacy learning", - "New learning", + await expect(loadSessionLearnings(opsStorePath, "msteams:user1")).resolves.toEqual([ + "Avoid bullets", ]); - await expect(loadSessionLearnings(tmpDir, "msteams/user1")).resolves.toStrictEqual([]); - await expect( - import("node:fs/promises").then((fs) => fs.readFile(migratedFile, "utf-8")), - ).resolves.toContain("Legacy learning"); }); }); diff --git a/extensions/nostr/doctor-contract-api.test.ts b/extensions/nostr/doctor-contract-api.test.ts new file mode 100644 index 00000000000..ea9152ee758 --- /dev/null +++ b/extensions/nostr/doctor-contract-api.test.ts @@ -0,0 +1,104 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +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 } from "vitest"; +import { stateMigrations } from "./doctor-contract-api.js"; + +function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext { + return { + openPluginStateKeyedStore(options: OpenKeyedStoreOptions) { + return createPluginStateKeyedStoreForTests("nostr", { + ...options, + env: options.env ?? env, + }); + }, + }; +} + +describe("nostr doctor state migration", () => { + let stateDir = ""; + let env: NodeJS.ProcessEnv; + + beforeEach(async () => { + resetPluginStateStoreForTests(); + stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-nostr-doctor-")); + env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + }); + + afterEach(async () => { + await fs.rm(stateDir, { recursive: true, force: true }); + }); + + it("imports legacy bus and profile state into plugin state", async () => { + const nostrDir = path.join(stateDir, "nostr"); + const busPath = path.join(nostrDir, "bus-state-main.json"); + const profilePath = path.join(nostrDir, "profile-state-main.json"); + await fs.mkdir(nostrDir, { recursive: true }); + await fs.writeFile( + busPath, + JSON.stringify({ + version: 1, + lastProcessedAt: 1700, + gatewayStartedAt: 1600, + }), + ); + await fs.writeFile( + profilePath, + JSON.stringify({ + version: 1, + lastPublishedAt: 1800, + lastPublishedEventId: "event-1", + lastPublishResults: { "wss://relay.example": "ok", bad: "nope" }, + }), + ); + + const context = createDoctorContext(env); + const busResult = await stateMigrations[0].migrateLegacyState({ + config: {}, + env, + stateDir, + oauthDir: path.join(stateDir, "oauth"), + context, + }); + const profileResult = await stateMigrations[1].migrateLegacyState({ + config: {}, + env, + stateDir, + oauthDir: path.join(stateDir, "oauth"), + context, + }); + + expect(busResult.warnings).toEqual([]); + expect(profileResult.warnings).toEqual([]); + await expect(fs.access(busPath)).rejects.toThrow(); + await expect(fs.access(profilePath)).rejects.toThrow(); + await expect(fs.access(`${busPath}.migrated`)).resolves.toBeUndefined(); + await expect(fs.access(`${profilePath}.migrated`)).resolves.toBeUndefined(); + await expect( + context.openPluginStateKeyedStore({ namespace: "bus-state", maxEntries: 256 }).lookup("main"), + ).resolves.toEqual({ + version: 2, + lastProcessedAt: 1700, + gatewayStartedAt: 1600, + recentEventIds: [], + }); + await expect( + context + .openPluginStateKeyedStore({ namespace: "profile-state", maxEntries: 256 }) + .lookup("main"), + ).resolves.toEqual({ + version: 1, + lastPublishedAt: 1800, + lastPublishedEventId: "event-1", + lastPublishResults: { "wss://relay.example": "ok" }, + }); + }); +}); diff --git a/extensions/nostr/doctor-contract-api.ts b/extensions/nostr/doctor-contract-api.ts new file mode 100644 index 00000000000..1b76b3e6481 --- /dev/null +++ b/extensions/nostr/doctor-contract-api.ts @@ -0,0 +1,296 @@ +import type { Dirent } from "node:fs"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor"; + +type NostrBusState = { + version: 2; + lastProcessedAt: number | null; + gatewayStartedAt: number | null; + recentEventIds: string[]; +}; + +type NostrProfileState = { + version: 1; + lastPublishedAt: number | null; + lastPublishedEventId: string | null; + lastPublishResults: Record | null; +}; + +const BUS_STATE_NAMESPACE = "bus-state"; +const PROFILE_STATE_NAMESPACE = "profile-state"; +const MAX_NOSTR_STATE_ENTRIES = 256; + +function normalizeAccountId(accountId?: string): string { + const trimmed = accountId?.trim(); + if (!trimmed) { + return "default"; + } + return trimmed.replace(/[^a-z0-9._-]+/gi, "_"); +} + +function finiteNumberOrNull(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function parseBusState(value: unknown): NostrBusState | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const parsed = value as Record; + if (parsed.version !== 1 && parsed.version !== 2) { + return null; + } + return { + version: 2, + lastProcessedAt: finiteNumberOrNull(parsed.lastProcessedAt), + gatewayStartedAt: finiteNumberOrNull(parsed.gatewayStartedAt), + recentEventIds: + parsed.version === 2 && Array.isArray(parsed.recentEventIds) + ? parsed.recentEventIds.filter((entry): entry is string => typeof entry === "string") + : [], + }; +} + +function parseProfileState(value: unknown): NostrProfileState | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const parsed = value as Record; + if (parsed.version !== 1) { + return null; + } + const rawResults = parsed.lastPublishResults; + const lastPublishResults: Record = {}; + if (rawResults && typeof rawResults === "object" && !Array.isArray(rawResults)) { + for (const [relay, result] of Object.entries(rawResults)) { + if (result === "ok" || result === "failed" || result === "timeout") { + lastPublishResults[relay] = result; + } + } + } + return { + version: 1, + lastPublishedAt: finiteNumberOrNull(parsed.lastPublishedAt), + lastPublishedEventId: + typeof parsed.lastPublishedEventId === "string" ? parsed.lastPublishedEventId : null, + lastPublishResults: + rawResults === null || Object.keys(lastPublishResults).length === 0 + ? null + : lastPublishResults, + }; +} + +async function fileExists(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + return stat.isFile(); + } catch { + return false; + } +} + +async function readJsonFile(filePath: string): Promise { + return JSON.parse(await fs.readFile(filePath, "utf8")) as unknown; +} + +async function listLegacyFiles(params: { + stateDir: string; + prefix: string; + parse: (value: unknown) => unknown; +}): Promise> { + const dir = path.join(params.stateDir, "nostr"); + let entries: Dirent[] = []; + try { + entries = await fs.readdir(dir, { withFileTypes: true }); + } catch { + return []; + } + const suffix = ".json"; + const files: Array<{ accountId: string; filePath: string; value: unknown }> = []; + for (const entry of entries) { + if (!entry.isFile() || !entry.name.startsWith(params.prefix) || !entry.name.endsWith(suffix)) { + continue; + } + const rawAccountId = entry.name.slice(params.prefix.length, -suffix.length); + const accountId = normalizeAccountId(rawAccountId); + const filePath = path.join(dir, entry.name); + try { + const value = params.parse(await readJsonFile(filePath)); + if (value) { + files.push({ accountId, filePath, value }); + } + } catch { + // Malformed legacy cache/cursor files are ignored by migration. + } + } + return files; +} + +async function archiveLegacySource(params: { + filePath: string; + label: string; + changes: string[]; + warnings: string[]; +}): Promise { + const archivedPath = `${params.filePath}.migrated`; + if (await fileExists(archivedPath)) { + params.warnings.push( + `Left migrated ${params.label} source in place because ${archivedPath} already exists`, + ); + return; + } + try { + await fs.rename(params.filePath, archivedPath); + params.changes.push(`Archived ${params.label} legacy source -> ${archivedPath}`); + } catch (err) { + params.warnings.push(`Failed archiving ${params.label} legacy source: ${String(err)}`); + } +} + +async function ensureStoreCapacity(params: { + files: Array<{ accountId: string }>; + store: { entries: () => Promise> }; + maxEntries: number; + label: string; + warnings: string[]; +}): Promise | null> { + const existingKeys = new Set((await params.store.entries()).map((entry) => entry.key)); + const missingKeys = new Set( + params.files.map((file) => file.accountId).filter((key) => !existingKeys.has(key)), + ); + if (missingKeys.size > params.maxEntries - existingKeys.size) { + params.warnings.push( + `Skipped migrating ${params.label} because plugin state has room for ${params.maxEntries - existingKeys.size} of ${missingKeys.size} missing entries; left legacy sources in place`, + ); + return null; + } + return existingKeys; +} + +export const stateMigrations: PluginDoctorStateMigration[] = [ + { + id: "nostr-bus-state-json-to-plugin-state", + label: "Nostr bus state", + async detectLegacyState(params) { + const files = await listLegacyFiles({ + stateDir: params.stateDir, + prefix: "bus-state-", + parse: parseBusState, + }); + if (files.length === 0) { + return null; + } + return { + preview: [ + `- Nostr bus state: ${files.length} ${files.length === 1 ? "account" : "accounts"} -> plugin state (${BUS_STATE_NAMESPACE})`, + ], + }; + }, + async migrateLegacyState(params) { + const changes: string[] = []; + const warnings: string[] = []; + const files = await listLegacyFiles({ + stateDir: params.stateDir, + prefix: "bus-state-", + parse: parseBusState, + }); + const store = params.context.openPluginStateKeyedStore({ + namespace: BUS_STATE_NAMESPACE, + maxEntries: MAX_NOSTR_STATE_ENTRIES, + }); + const existingKeys = await ensureStoreCapacity({ + files, + store, + maxEntries: MAX_NOSTR_STATE_ENTRIES, + label: "Nostr bus state", + warnings, + }); + if (!existingKeys) { + return { changes, warnings }; + } + let imported = 0; + for (const file of files) { + if (!existingKeys.has(file.accountId)) { + await store.register(file.accountId, file.value as NostrBusState); + existingKeys.add(file.accountId); + imported++; + } + await archiveLegacySource({ + filePath: file.filePath, + label: "Nostr bus state", + changes, + warnings, + }); + } + if (imported > 0) { + changes.unshift( + `Migrated ${imported} Nostr bus-state ${imported === 1 ? "entry" : "entries"} -> plugin state`, + ); + } + return { changes, warnings }; + }, + }, + { + id: "nostr-profile-state-json-to-plugin-state", + label: "Nostr profile state", + async detectLegacyState(params) { + const files = await listLegacyFiles({ + stateDir: params.stateDir, + prefix: "profile-state-", + parse: parseProfileState, + }); + if (files.length === 0) { + return null; + } + return { + preview: [ + `- Nostr profile state: ${files.length} ${files.length === 1 ? "account" : "accounts"} -> plugin state (${PROFILE_STATE_NAMESPACE})`, + ], + }; + }, + async migrateLegacyState(params) { + const changes: string[] = []; + const warnings: string[] = []; + const files = await listLegacyFiles({ + stateDir: params.stateDir, + prefix: "profile-state-", + parse: parseProfileState, + }); + const store = params.context.openPluginStateKeyedStore({ + namespace: PROFILE_STATE_NAMESPACE, + maxEntries: MAX_NOSTR_STATE_ENTRIES, + }); + const existingKeys = await ensureStoreCapacity({ + files, + store, + maxEntries: MAX_NOSTR_STATE_ENTRIES, + label: "Nostr profile state", + warnings, + }); + if (!existingKeys) { + return { changes, warnings }; + } + let imported = 0; + for (const file of files) { + if (!existingKeys.has(file.accountId)) { + await store.register(file.accountId, file.value as NostrProfileState); + existingKeys.add(file.accountId); + imported++; + } + await archiveLegacySource({ + filePath: file.filePath, + label: "Nostr profile state", + changes, + warnings, + }); + } + if (imported > 0) { + changes.unshift( + `Migrated ${imported} Nostr profile-state ${imported === 1 ? "entry" : "entries"} -> plugin state`, + ); + } + return { changes, warnings }; + }, + }, +]; diff --git a/extensions/nostr/src/nostr-state-store.test.ts b/extensions/nostr/src/nostr-state-store.test.ts index 238ca255186..a9785d2e511 100644 --- a/extensions/nostr/src/nostr-state-store.test.ts +++ b/extensions/nostr/src/nostr-state-store.test.ts @@ -1,6 +1,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; +import { + createPluginStateKeyedStoreForTests, + resetPluginStateStoreForTests, +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { describe, expect, it } from "vitest"; import type { PluginRuntime } from "../runtime-api.js"; import { @@ -16,8 +21,14 @@ async function withTempStateDir(fn: (dir: string) => Promise) { const previous = process.env.OPENCLAW_STATE_DIR; const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-nostr-")); process.env.OPENCLAW_STATE_DIR = dir; + resetPluginStateStoreForTests(); setNostrRuntime({ state: { + openKeyedStore: (options: OpenKeyedStoreOptions) => + createPluginStateKeyedStoreForTests("nostr", { + ...options, + env: { ...process.env, OPENCLAW_STATE_DIR: dir }, + }), resolveStateDir: (env, homedir) => { const stateEnv = env ?? process.env; const override = stateEnv.OPENCLAW_STATE_DIR?.trim(); @@ -85,55 +96,6 @@ describe("nostr bus state store", () => { expect(stateB?.lastProcessedAt).toBe(2000); }); }); - - it("upgrades v1 bus state files on read", async () => { - await withTempStateDir(async (dir) => { - const filePath = path.join(dir, "nostr", "bus-state-test-bot.json"); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile( - filePath, - JSON.stringify({ - version: 1, - lastProcessedAt: 1700000000, - gatewayStartedAt: 1700000100, - }), - "utf-8", - ); - - const state = await readNostrBusState({ accountId: "test-bot" }); - expect(state).toEqual({ - version: 2, - lastProcessedAt: 1700000000, - gatewayStartedAt: 1700000100, - recentEventIds: [], - }); - }); - }); - - it("drops malformed recent event ids while keeping the state", async () => { - await withTempStateDir(async (dir) => { - const filePath = path.join(dir, "nostr", "bus-state-test-bot.json"); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile( - filePath, - JSON.stringify({ - version: 2, - lastProcessedAt: 1700000000, - gatewayStartedAt: 1700000100, - recentEventIds: ["evt-1", 2, null], - }), - "utf-8", - ); - - const state = await readNostrBusState({ accountId: "test-bot" }); - expect(state).toEqual({ - version: 2, - lastProcessedAt: 1700000000, - gatewayStartedAt: 1700000100, - recentEventIds: ["evt-1"], - }); - }); - }); }); describe("nostr profile state store", () => { @@ -159,34 +121,6 @@ describe("nostr profile state store", () => { }); }); }); - - it("drops malformed relay results while keeping valid state fields", async () => { - await withTempStateDir(async (dir) => { - const filePath = path.join(dir, "nostr", "profile-state-test-bot.json"); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile( - filePath, - JSON.stringify({ - version: 1, - lastPublishedAt: 1700000000, - lastPublishedEventId: "evt-1", - lastPublishResults: { - "wss://relay.example": "ok", - "wss://relay.bad": "unknown", - }, - }), - "utf-8", - ); - - const state = await readNostrProfileState({ accountId: "test-bot" }); - expect(state).toEqual({ - version: 1, - lastPublishedAt: 1700000000, - lastPublishedEventId: "evt-1", - lastPublishResults: null, - }); - }); - }); }); describe("computeSinceTimestamp", () => { diff --git a/extensions/nostr/src/nostr-state-store.ts b/extensions/nostr/src/nostr-state-store.ts index bcc5c91f7da..ebcdd3446d0 100644 --- a/extensions/nostr/src/nostr-state-store.ts +++ b/extensions/nostr/src/nostr-state-store.ts @@ -1,8 +1,3 @@ -import os from "node:os"; -import path from "node:path"; -import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared"; -import { privateFileStore } from "openclaw/plugin-sdk/security-runtime"; -import { z } from "zod"; import { getNostrRuntime } from "./runtime.js"; const STORE_VERSION = 2; @@ -29,33 +24,6 @@ type NostrProfileState = { lastPublishResults: Record | null; }; -const NullableFiniteNumberSchema = z.number().finite().nullable().catch(null); -const NostrBusStateV1Schema = z.object({ - version: z.literal(1), - lastProcessedAt: NullableFiniteNumberSchema, - gatewayStartedAt: NullableFiniteNumberSchema, -}); - -const NostrBusStateSchema = z.object({ - version: z.literal(2), - lastProcessedAt: NullableFiniteNumberSchema, - gatewayStartedAt: NullableFiniteNumberSchema, - recentEventIds: z - .array(z.unknown()) - .catch([]) - .transform((ids) => ids.filter((id): id is string => typeof id === "string")), -}); - -const NostrProfileStateSchema = z.object({ - version: z.literal(1), - lastPublishedAt: NullableFiniteNumberSchema, - lastPublishedEventId: z.string().nullable().catch(null), - lastPublishResults: z - .record(z.string(), z.enum(["ok", "failed", "timeout"])) - .nullable() - .catch(null), -}); - function normalizeAccountId(accountId?: string): string { const trimmed = accountId?.trim(); if (!trimmed) { @@ -64,57 +32,29 @@ function normalizeAccountId(accountId?: string): string { return trimmed.replace(/[^a-z0-9._-]+/gi, "_"); } -function resolveNostrStatePath(accountId?: string, env: NodeJS.ProcessEnv = process.env): string { - const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir); - const normalized = normalizeAccountId(accountId); - return path.join(stateDir, "nostr", `bus-state-${normalized}.json`); +function openNostrBusStateStore(env?: NodeJS.ProcessEnv) { + return getNostrRuntime().state.openKeyedStore({ + namespace: "bus-state", + maxEntries: 256, + ...(env ? { env } : {}), + }); } -function resolveNostrProfileStatePath( - accountId?: string, - env: NodeJS.ProcessEnv = process.env, -): string { - const stateDir = getNostrRuntime().state.resolveStateDir(env, os.homedir); - const normalized = normalizeAccountId(accountId); - return path.join(stateDir, "nostr", `profile-state-${normalized}.json`); -} - -function safeParseState(raw: string): NostrBusState | null { - const parsedV2 = safeParseJsonWithSchema(NostrBusStateSchema, raw); - if (parsedV2) { - return parsedV2; - } - - const parsedV1 = safeParseJsonWithSchema(NostrBusStateV1Schema, raw); - if (!parsedV1) { - return null; - } - - // Back-compat: v1 state files - return { - version: 2, - lastProcessedAt: parsedV1.lastProcessedAt, - gatewayStartedAt: parsedV1.gatewayStartedAt, - recentEventIds: [], - }; +function openNostrProfileStateStore(env?: NodeJS.ProcessEnv) { + return getNostrRuntime().state.openKeyedStore({ + namespace: "profile-state", + maxEntries: 256, + ...(env ? { env } : {}), + }); } export async function readNostrBusState(params: { accountId?: string; env?: NodeJS.ProcessEnv; }): Promise { - const filePath = resolveNostrStatePath(params.accountId, params.env); - try { - const raw = await privateFileStore(path.dirname(filePath)).readTextIfExists( - path.basename(filePath), - ); - if (raw === null) { - return null; - } - return safeParseState(raw); - } catch { - return null; - } + return ( + (await openNostrBusStateStore(params.env).lookup(normalizeAccountId(params.accountId))) ?? null + ); } export async function writeNostrBusState(params: { @@ -124,21 +64,18 @@ export async function writeNostrBusState(params: { recentEventIds?: string[]; env?: NodeJS.ProcessEnv; }): Promise { - const filePath = resolveNostrStatePath(params.accountId, params.env); const payload: NostrBusState = { version: STORE_VERSION, lastProcessedAt: params.lastProcessedAt, gatewayStartedAt: params.gatewayStartedAt, recentEventIds: (params.recentEventIds ?? []).filter((x): x is string => typeof x === "string"), }; - await privateFileStore(path.dirname(filePath)).writeJson(path.basename(filePath), payload, { - trailingNewline: true, - }); + await openNostrBusStateStore(params.env).register(normalizeAccountId(params.accountId), payload); } /** * Determine the `since` timestamp for subscription. - * Returns the later of: lastProcessedAt or gatewayStartedAt (both from disk), + * Returns the later of: lastProcessedAt or gatewayStartedAt (both from state), * falling back to `now` for fresh starts. */ export function computeSinceTimestamp( @@ -164,26 +101,14 @@ export function computeSinceTimestamp( // Profile State Management // ============================================================================ -function safeParseProfileState(raw: string): NostrProfileState | null { - return safeParseJsonWithSchema(NostrProfileStateSchema, raw); -} - export async function readNostrProfileState(params: { accountId?: string; env?: NodeJS.ProcessEnv; }): Promise { - const filePath = resolveNostrProfileStatePath(params.accountId, params.env); - try { - const raw = await privateFileStore(path.dirname(filePath)).readTextIfExists( - path.basename(filePath), - ); - if (raw === null) { - return null; - } - return safeParseProfileState(raw); - } catch { - return null; - } + return ( + (await openNostrProfileStateStore(params.env).lookup(normalizeAccountId(params.accountId))) ?? + null + ); } export async function writeNostrProfileState(params: { @@ -193,14 +118,14 @@ export async function writeNostrProfileState(params: { lastPublishResults: Record; env?: NodeJS.ProcessEnv; }): Promise { - const filePath = resolveNostrProfileStatePath(params.accountId, params.env); const payload: NostrProfileState = { version: PROFILE_STATE_VERSION, lastPublishedAt: params.lastPublishedAt, lastPublishedEventId: params.lastPublishedEventId, lastPublishResults: params.lastPublishResults, }; - await privateFileStore(path.dirname(filePath)).writeJson(path.basename(filePath), payload, { - trailingNewline: true, - }); + await openNostrProfileStateStore(params.env).register( + normalizeAccountId(params.accountId), + payload, + ); } diff --git a/extensions/phone-control/doctor-contract-api.test.ts b/extensions/phone-control/doctor-contract-api.test.ts new file mode 100644 index 00000000000..f5b94e008fd --- /dev/null +++ b/extensions/phone-control/doctor-contract-api.test.ts @@ -0,0 +1,91 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +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 } from "vitest"; +import { stateMigrations } from "./doctor-contract-api.js"; + +function createDoctorContext(env: NodeJS.ProcessEnv): PluginDoctorStateMigrationContext { + return { + openPluginStateKeyedStore(options: OpenKeyedStoreOptions) { + return createPluginStateKeyedStoreForTests("phone-control", { + ...options, + env: options.env ?? env, + }); + }, + }; +} + +describe("phone-control doctor state migration", () => { + let stateDir = ""; + let env: NodeJS.ProcessEnv; + + beforeEach(async () => { + resetPluginStateStoreForTests(); + stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-phone-control-doctor-")); + env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; + }); + + afterEach(async () => { + await fs.rm(stateDir, { recursive: true, force: true }); + }); + + it("imports legacy armed state into plugin state", async () => { + const sourcePath = path.join(stateDir, "plugins", "phone-control", "armed.json"); + const legacyState = { + version: 2, + armedAtMs: 100, + expiresAtMs: 200, + group: "writes", + armedCommands: ["sms.send"], + addedToAllow: ["sms.send"], + removedFromDeny: [], + }; + await fs.mkdir(path.dirname(sourcePath), { recursive: true }); + await fs.writeFile(sourcePath, JSON.stringify(legacyState)); + + const migration = stateMigrations[0]; + await expect( + migration.detectLegacyState({ + config: {}, + env, + stateDir, + oauthDir: path.join(stateDir, "oauth"), + context: createDoctorContext(env), + }), + ).resolves.toMatchObject({ + preview: [expect.stringContaining("Phone Control armed state")], + }); + + const result = await migration.migrateLegacyState({ + config: {}, + env, + stateDir, + oauthDir: path.join(stateDir, "oauth"), + context: createDoctorContext(env), + }); + + expect(result.warnings).toEqual([]); + expect(result.changes).toEqual([ + "Migrated Phone Control armed state -> plugin state", + expect.stringContaining("Archived Phone Control armed-state legacy source"), + ]); + await expect(fs.access(sourcePath)).rejects.toThrow(); + await expect(fs.access(`${sourcePath}.migrated`)).resolves.toBeUndefined(); + await expect( + createDoctorContext(env) + .openPluginStateKeyedStore({ + namespace: "armed", + maxEntries: 1, + }) + .lookup("current"), + ).resolves.toEqual(legacyState); + }); +}); diff --git a/extensions/phone-control/doctor-contract-api.ts b/extensions/phone-control/doctor-contract-api.ts new file mode 100644 index 00000000000..d952f0867a5 --- /dev/null +++ b/extensions/phone-control/doctor-contract-api.ts @@ -0,0 +1,162 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import type { PluginDoctorStateMigration } from "openclaw/plugin-sdk/runtime-doctor"; + +type ArmGroup = "camera" | "screen" | "writes" | "all"; + +type ArmStateFileV1 = { + version: 1; + armedAtMs: number; + expiresAtMs: number | null; + removedFromDeny: string[]; +}; + +type ArmStateFileV2 = { + version: 2; + armedAtMs: number; + expiresAtMs: number | null; + group: ArmGroup; + armedCommands: string[]; + addedToAllow: string[]; + removedFromDeny: string[]; +}; + +type ArmStateFile = ArmStateFileV1 | ArmStateFileV2; + +const ARM_STATE_NAMESPACE = "armed"; +const ARM_STATE_KEY = "current"; + +function resolveArmStatePath(stateDir: string): string { + return path.join(stateDir, "plugins", "phone-control", "armed.json"); +} + +async function fileExists(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + return stat.isFile(); + } catch { + return false; + } +} + +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((entry) => typeof entry === "string"); +} + +function parseArmState(value: unknown): ArmStateFile | null { + if (!value || typeof value !== "object" || Array.isArray(value)) { + return null; + } + const parsed = value as Record; + if (parsed.version !== 1 && parsed.version !== 2) { + return null; + } + if (typeof parsed.armedAtMs !== "number") { + return null; + } + if (!(parsed.expiresAtMs === null || typeof parsed.expiresAtMs === "number")) { + return null; + } + if (parsed.version === 1) { + if (!isStringArray(parsed.removedFromDeny)) { + return null; + } + return { + version: 1, + armedAtMs: parsed.armedAtMs, + expiresAtMs: parsed.expiresAtMs, + removedFromDeny: parsed.removedFromDeny, + }; + } + const group = typeof parsed.group === "string" ? parsed.group : ""; + if (group !== "camera" && group !== "screen" && group !== "writes" && group !== "all") { + return null; + } + if ( + !isStringArray(parsed.armedCommands) || + !isStringArray(parsed.addedToAllow) || + !isStringArray(parsed.removedFromDeny) + ) { + return null; + } + return { + version: 2, + armedAtMs: parsed.armedAtMs, + expiresAtMs: parsed.expiresAtMs, + group, + armedCommands: parsed.armedCommands, + addedToAllow: parsed.addedToAllow, + removedFromDeny: parsed.removedFromDeny, + }; +} + +async function readLegacyArmState(filePath: string): Promise { + try { + return parseArmState(JSON.parse(await fs.readFile(filePath, "utf8")) as unknown); + } catch { + return null; + } +} + +async function archiveLegacySource(params: { + filePath: string; + changes: string[]; + warnings: string[]; +}): Promise { + const archivedPath = `${params.filePath}.migrated`; + if (await fileExists(archivedPath)) { + params.warnings.push( + `Left migrated Phone Control armed-state source in place because ${archivedPath} already exists`, + ); + return; + } + try { + await fs.rename(params.filePath, archivedPath); + params.changes.push(`Archived Phone Control armed-state legacy source -> ${archivedPath}`); + } catch (err) { + params.warnings.push( + `Failed archiving Phone Control armed-state legacy source: ${String(err)}`, + ); + } +} + +export const stateMigrations: PluginDoctorStateMigration[] = [ + { + id: "phone-control-armed-json-to-plugin-state", + label: "Phone Control armed state", + async detectLegacyState(params) { + const filePath = resolveArmStatePath(params.stateDir); + const state = await readLegacyArmState(filePath); + if (!state) { + return null; + } + return { + preview: [ + `- Phone Control armed state: ${filePath} -> plugin state (${ARM_STATE_NAMESPACE})`, + ], + }; + }, + async migrateLegacyState(params) { + const changes: string[] = []; + const warnings: string[] = []; + const filePath = resolveArmStatePath(params.stateDir); + const state = await readLegacyArmState(filePath); + if (!state) { + return { changes, warnings }; + } + const store = params.context.openPluginStateKeyedStore({ + namespace: ARM_STATE_NAMESPACE, + maxEntries: 1, + }); + const existing = await store.lookup(ARM_STATE_KEY); + if (existing) { + warnings.push("Left Phone Control armed-state source in place because plugin state exists"); + return { changes, warnings }; + } + await store.register(ARM_STATE_KEY, state); + changes.push("Migrated Phone Control armed state -> plugin state"); + await archiveLegacySource({ filePath, changes, warnings }); + return { changes, warnings }; + }, + }, +]; diff --git a/extensions/phone-control/index.test.ts b/extensions/phone-control/index.test.ts index dbc32bb7805..f7916d65caa 100644 --- a/extensions/phone-control/index.test.ts +++ b/extensions/phone-control/index.test.ts @@ -1,8 +1,13 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime"; +import { + createPluginStateKeyedStoreForTests, + resetPluginStateStoreForTests, +} from "openclaw/plugin-sdk/plugin-state-test-runtime"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import registerPhoneControl from "./index.js"; import type { OpenClawPluginApi, @@ -28,6 +33,11 @@ function createApi(params: { runtime: { state: { resolveStateDir: () => params.stateDir, + openKeyedStore: (options: OpenKeyedStoreOptions) => + createPluginStateKeyedStoreForTests("phone-control", { + ...options, + env: { ...process.env, OPENCLAW_STATE_DIR: params.stateDir }, + }), }, config: { current: () => params.getConfig(), @@ -126,6 +136,10 @@ async function withRegisteredPhoneControl( } describe("phone-control plugin", () => { + beforeEach(() => { + resetPluginStateStoreForTests(); + }); + it("arms sms.send as part of the writes group", async () => { await withRegisteredPhoneControl(async ({ command, writeConfigFile, getConfig }) => { expect(command.name).toBe("phone"); diff --git a/extensions/phone-control/index.ts b/extensions/phone-control/index.ts index 17b7cc9c16b..31b6b3b53e9 100644 --- a/extensions/phone-control/index.ts +++ b/extensions/phone-control/index.ts @@ -1,11 +1,8 @@ -import fs from "node:fs/promises"; -import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { asDateTimestampMs, resolveExpiresAtMsFromDurationMs, } from "openclaw/plugin-sdk/number-runtime"; -import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, @@ -40,7 +37,8 @@ type ArmStateFileV2 = { type ArmStateFile = ArmStateFileV1 | ArmStateFileV2; const STATE_VERSION = 2; -const STATE_REL_PATH = ["plugins", "phone-control", "armed.json"] as const; +const ARM_STATE_NAMESPACE = "armed"; +const ARM_STATE_KEY = "current"; const PHONE_ADMIN_SCOPE = "operator.admin"; const GROUP_COMMANDS: Record, string[]> = { @@ -100,77 +98,24 @@ function formatDuration(ms: number): string { return `${d}d`; } -function resolveStatePath(stateDir: string): string { - return path.join(stateDir, ...STATE_REL_PATH); +function openArmStateStore(api: OpenClawPluginApi) { + return api.runtime.state.openKeyedStore({ + namespace: ARM_STATE_NAMESPACE, + maxEntries: 1, + }); } -async function readArmState(statePath: string): Promise { - try { - const raw = await fs.readFile(statePath, "utf8"); - // Type as unknown record first to allow property access during validation - const parsed = JSON.parse(raw) as Record; - if (parsed.version !== 1 && parsed.version !== 2) { - return null; - } - if (typeof parsed.armedAtMs !== "number") { - return null; - } - if (!(parsed.expiresAtMs === null || typeof parsed.expiresAtMs === "number")) { - return null; - } - - if (parsed.version === 1) { - if ( - !Array.isArray(parsed.removedFromDeny) || - !parsed.removedFromDeny.every((v: unknown) => typeof v === "string") - ) { - return null; - } - return parsed as unknown as ArmStateFile; - } - - const group = typeof parsed.group === "string" ? parsed.group : ""; - if (group !== "camera" && group !== "screen" && group !== "writes" && group !== "all") { - return null; - } - if ( - !Array.isArray(parsed.armedCommands) || - !parsed.armedCommands.every((v: unknown) => typeof v === "string") - ) { - return null; - } - if ( - !Array.isArray(parsed.addedToAllow) || - !parsed.addedToAllow.every((v: unknown) => typeof v === "string") - ) { - return null; - } - if ( - !Array.isArray(parsed.removedFromDeny) || - !parsed.removedFromDeny.every((v: unknown) => typeof v === "string") - ) { - return null; - } - return parsed as unknown as ArmStateFile; - } catch { - return null; - } +async function readArmState(api: OpenClawPluginApi): Promise { + return (await openArmStateStore(api).lookup(ARM_STATE_KEY)) ?? null; } -async function writeArmState(statePath: string, state: ArmStateFile | null): Promise { +async function writeArmState(api: OpenClawPluginApi, state: ArmStateFile | null): Promise { + const store = openArmStateStore(api); if (!state) { - try { - await fs.unlink(statePath); - } catch { - // ignore - } + await store.delete(ARM_STATE_KEY); return; } - await replaceFileAtomic({ - filePath: statePath, - content: `${JSON.stringify(state, null, 2)}\n`, - tempPrefix: ".phone-control-arm", - }); + await store.register(ARM_STATE_KEY, state); } function normalizeDenyList(cfg: OpenClawPluginApi["config"]): string[] { @@ -200,12 +145,10 @@ function patchConfigNodeLists( async function disarmNow(params: { api: OpenClawPluginApi; - stateDir: string; - statePath: string; reason: string; }): Promise<{ changed: boolean; restored: string[]; removed: string[] }> { - const { api, stateDir, statePath, reason } = params; - const state = await readArmState(statePath); + const { api, reason } = params; + const state = await readArmState(api); if (!state) { return { changed: false, restored: [], removed: [] }; } @@ -248,8 +191,8 @@ async function disarmNow(params: { }, }); } - await writeArmState(statePath, null); - api.logger.info(`phone-control: disarmed (${reason}) stateDir=${stateDir}`); + await writeArmState(api, null); + api.logger.info(`phone-control: disarmed (${reason})`); return { changed: removed.length > 0 || restored.length > 0, removed: uniqSorted(removed), @@ -350,10 +293,9 @@ export default definePluginEntry({ const timerService: OpenClawPluginService = { id: "phone-control-expiry", - start: async (ctx) => { - const statePath = resolveStatePath(ctx.stateDir); + start: async () => { const tick = async () => { - const state = await readArmState(statePath); + const state = await readArmState(api); if (!state || state.expiresAtMs == null) { return; } @@ -362,8 +304,6 @@ export default definePluginEntry({ } await disarmNow({ api, - stateDir: ctx.stateDir, - statePath, reason: "expired", }); }; @@ -396,16 +336,13 @@ export default definePluginEntry({ const tokens = args.split(/\s+/).filter(Boolean); const action = normalizeLowercaseStringOrEmpty(tokens[0]); - const stateDir = api.runtime.state.resolveStateDir(); - const statePath = resolveStatePath(stateDir); - if (!action || action === "help") { - const state = await readArmState(statePath); + const state = await readArmState(api); return { text: `${formatStatus(state)}\n\n${formatHelp()}` }; } if (action === "status") { - const state = await readArmState(statePath); + const state = await readArmState(api); return { text: formatStatus(state) }; } @@ -422,8 +359,6 @@ export default definePluginEntry({ } const res = await disarmNow({ api, - stateDir, - statePath, reason: "manual", }); if (!res.changed) { @@ -491,7 +426,7 @@ export default definePluginEntry({ }, }); - await writeArmState(statePath, { + await writeArmState(api, { version: STATE_VERSION, armedAtMs, expiresAtMs, diff --git a/src/plugins/doctor-contract-registry.load-paths.test.ts b/src/plugins/doctor-contract-registry.load-paths.test.ts index 5223fb7f167..8fbbb4d446c 100644 --- a/src/plugins/doctor-contract-registry.load-paths.test.ts +++ b/src/plugins/doctor-contract-registry.load-paths.test.ts @@ -94,6 +94,55 @@ module.exports = { ); } +function writeDistDoctorPlugin(pluginRoot: string, pluginId: string): void { + fs.mkdirSync(path.join(pluginRoot, "dist"), { recursive: true }); + fs.writeFileSync( + path.join(pluginRoot, "openclaw.plugin.json"), + JSON.stringify( + { + id: pluginId, + name: "Dist Doctor", + version: "0.0.0-test", + configSchema: {}, + }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync( + path.join(pluginRoot, "package.json"), + JSON.stringify( + { + name: `@openclaw/${pluginId}`, + version: "0.0.0-test", + type: "module", + openclaw: { + extensions: ["./dist/index.js"], + }, + }, + null, + 2, + ), + "utf8", + ); + fs.writeFileSync(path.join(pluginRoot, "dist", "index.js"), "export {};\n", "utf8"); + fs.writeFileSync( + path.join(pluginRoot, "dist", "doctor-contract-api.cjs"), + ` +module.exports = { + legacyConfigRules: [ + { + path: ["plugins", "entries", ${JSON.stringify(pluginId)}, "config", "distOnly"], + message: "dist doctor contract warning", + }, + ], +}; +`, + "utf8", + ); +} + function writeDoctorSessionOwnerPlugin(pluginRoot: string, pluginId: string): void { fs.mkdirSync(pluginRoot, { recursive: true }); fs.writeFileSync( @@ -192,6 +241,26 @@ describe("doctor contract registry load-path plugins", () => { ]); }); + it("discovers doctor warning rules from package dist contracts", () => { + const stateDir = makeTempDir(); + const pluginRoot = makeTempDir(); + const pluginId = "dist-doctor"; + writeDistDoctorPlugin(pluginRoot, pluginId); + const config = createDoctorPluginConfig(pluginRoot, pluginId); + + const rules = listPluginDoctorLegacyConfigRules({ + config, + env: makeHermeticDoctorEnv(stateDir), + pluginIds: [pluginId], + }); + expect(rules).toEqual([ + { + path: ["plugins", "entries", pluginId, "config", "distOnly"], + message: "dist doctor contract warning", + }, + ]); + }); + it("applies compatibility normalizers from plugins.load.paths", () => { const stateDir = makeTempDir(); const pluginRoot = makeTempDir(); diff --git a/src/plugins/doctor-contract-registry.ts b/src/plugins/doctor-contract-registry.ts index 51e331fc68e..44b67bb27e9 100644 --- a/src/plugins/doctor-contract-registry.ts +++ b/src/plugins/doctor-contract-registry.ts @@ -104,16 +104,14 @@ function resolveContractApiPath(rootDir: string): string | null { const orderedExtensions = RUNNING_FROM_BUILT_ARTIFACT ? CONTRACT_API_EXTENSIONS : ([...CONTRACT_API_EXTENSIONS.slice(3), ...CONTRACT_API_EXTENSIONS.slice(0, 3)] as const); - for (const extension of orderedExtensions) { - const candidate = path.join(rootDir, `doctor-contract-api${extension}`); - if (fs.existsSync(candidate)) { - return candidate; - } - } - for (const extension of orderedExtensions) { - const candidate = path.join(rootDir, `contract-api${extension}`); - if (fs.existsSync(candidate)) { - return candidate; + for (const basename of ["doctor-contract-api", "contract-api"]) { + for (const extension of orderedExtensions) { + for (const baseDir of [rootDir, path.join(rootDir, "dist")]) { + const candidate = path.join(baseDir, `${basename}${extension}`); + if (fs.existsSync(candidate)) { + return candidate; + } + } } } return null; diff --git a/test/plugin-npm-runtime-build.test.ts b/test/plugin-npm-runtime-build.test.ts index 4909e61e337..f3eef8f7e20 100644 --- a/test/plugin-npm-runtime-build.test.ts +++ b/test/plugin-npm-runtime-build.test.ts @@ -85,4 +85,20 @@ describe("plugin npm runtime build planning", () => { "skills/**", ]); }); + + it("builds doctor contract surfaces for publishable channel plugins", () => { + for (const pluginDir of ["msteams", "nostr"]) { + const plan = expectPluginNpmRuntimeBuildPlan( + resolvePluginNpmRuntimeBuildPlan({ + repoRoot, + packageDir: path.join(repoRoot, "extensions", pluginDir), + }), + ); + expect(plan.entry["doctor-contract-api"]).toBe( + path.join(repoRoot, "extensions", pluginDir, "doctor-contract-api.ts"), + ); + expect(plan.runtimeBuildOutputs).toContain("./dist/doctor-contract-api.js"); + expect(plan.packageFiles).toContain("dist/**"); + } + }); });