diff --git a/repro-doctor-session-snapshot-repair.mjs b/repro-doctor-session-snapshot-repair.mjs deleted file mode 100644 index 10970d49c85..00000000000 --- a/repro-doctor-session-snapshot-repair.mjs +++ /dev/null @@ -1,354 +0,0 @@ -#!/usr/bin/env node -/** - * repro-doctor-session-snapshot-repair.mjs - * - * Real Behavior Proof for PR #85691: - * fix(doctor): auto-repair stale session snapshot paths on --fix - * - * Zero dependencies — run with: node repro-doctor-session-snapshot-repair.mjs - * - * Mirrors the exact repair logic from the PR (v2 — layered on current main scanner): - * - Uses extractBundledSkillRelativeSegments + resolveExpectedBundledSkillPath pattern - * - Scoped replacement: only modifies snapshot metadata fields - * - Handles JSON-escaped, XML-escaped, and raw path forms - * - Creates .bak backup before writing - * - Validates JSON integrity after repair - * - * Date: 2026-05-29 - * Node: v24.14.0 - * Platform: linux x64 - */ - -import { writeFileSync, readFileSync, mkdirSync, rmSync, existsSync, copyFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -const testDir = join(tmpdir(), `openclaw-doctor-proof-${Date.now()}`); -mkdirSync(testDir, { recursive: true }); - -const staleRoot = "/home/user/.local/share/pnpm/global/5/node_modules/openclaw@2026.5.20_@types+express@5.0.6"; -const liveRoot = "/home/user/.local/share/pnpm/global/5/node_modules/openclaw@2026.5.20"; - -function splitPathSegments(value) { - return value.replace(/^[a-z]:/i, "").replaceAll("\\", "/").split("/").filter(Boolean); -} - -function isWindowsAbsolutePath(value) { - return (/^[a-z]:/i.test(value) && ["/", "\\"].includes(value.slice(2, 3))) || value.startsWith("\\\\"); -} - -function isTempBackedOpenClawRoot(segments) { - const lower = segments.map(s => s.toLowerCase()); - const idx = lower.lastIndexOf("openclaw"); - return idx >= 1 && (lower[idx - 1] === "tmp" || lower[idx - 1] === "temp"); -} - -function isBundledRuntimeSkillsPath(cachedPath, skillRootIndex) { - const before = splitPathSegments(cachedPath).slice(0, skillRootIndex); - const lower = before.map(s => s.toLowerCase()); - return lower.some(s => s === "dist-runtime" || s === "node_modules" || s.startsWith("openclaw@")) - || isTempBackedOpenClawRoot(before); -} - -function extractBundledSkillRelativeSegments(cachedPath) { - const segments = splitPathSegments(cachedPath); - const skillRootIndex = segments.lastIndexOf("skills"); - if (skillRootIndex < 0 || !isBundledRuntimeSkillsPath(cachedPath, skillRootIndex)) return undefined; - const rel = segments.slice(skillRootIndex + 1); - if (rel.length < 2 || rel.at(-1) !== "SKILL.md") return undefined; - return rel; -} - -function resolveExpectedBundledSkillPath(cachedPath, bundledSkillsDir) { - const segments = splitPathSegments(cachedPath); - const rel = extractBundledSkillRelativeSegments(cachedPath); - if (!rel) return undefined; - const expected = [bundledSkillsDir, ...rel].join("/"); - return expected; -} - -function decodeXmlText(value) { - return value - .replace(/</g, "<").replace(/>/g, ">") - .replace(/"/g, '"').replace(/'/g, "'") - .replace(/&/g, "&"); -} - -function extractSkillLocations(prompt) { - if (typeof prompt !== "string" || !prompt.trim()) return []; - const locations = []; - const pattern = /([\s\S]*?)<\/location>/g; - for (const match of prompt.matchAll(pattern)) { - const raw = match[1]?.trim(); - if (raw) locations.push(decodeXmlText(raw)); - } - return locations; -} - -function collectResolvedSkillPaths(resolvedSkills) { - if (!Array.isArray(resolvedSkills)) return []; - const paths = []; - for (const skill of resolvedSkills) { - if (!skill || typeof skill !== "object") continue; - if (typeof skill.filePath === "string" && skill.filePath.trim()) paths.push(skill.filePath.trim()); - if (typeof skill.baseDir === "string" && skill.baseDir.trim()) paths.push(skill.baseDir.trim() + "/SKILL.md"); - } - return paths; -} - -function collectInjectedWorkspaceFilePaths(injected) { - if (!Array.isArray(injected)) return []; - return injected - .map(e => (e && typeof e === "object" && typeof e.path === "string" ? e.path.trim() : "")) - .filter(Boolean); -} - -function collectCachedSnapshotPaths(entry) { - const snapshot = entry.skillsSnapshot; - const report = entry.systemPromptReport; - const paths = []; - for (const loc of extractSkillLocations(snapshot?.prompt)) { - paths.push({ field: "skillsSnapshot.prompt", path: loc }); - } - for (const loc of collectResolvedSkillPaths(snapshot?.resolvedSkills)) { - paths.push({ field: "skillsSnapshot.resolvedSkills", path: loc }); - } - if (report && typeof report === "object") { - for (const loc of collectInjectedWorkspaceFilePaths(report.injectedWorkspaceFiles)) { - paths.push({ field: "systemPromptReport.injectedWorkspaceFiles", path: loc }); - } - } - return paths; -} - -function isInsidePath(baseDir, candidatePath) { - const baseIsWin = isWindowsAbsolutePath(baseDir); - const candIsWin = isWindowsAbsolutePath(candidatePath); - if (baseIsWin !== candIsWin) return false; - const normBase = baseDir.replaceAll("\\", "/"); - const normCand = candidatePath.replaceAll("\\", "/"); - return normCand.startsWith(normBase + "/") || normCand === normBase; -} - -function findStalePaths(store, bundledSkillsDir) { - const findings = []; - const seen = new Set(); - for (const [sessionKey, entry] of Object.entries(store)) { - if (!entry || typeof entry !== "object") continue; - for (const cached of collectCachedSnapshotPaths(entry)) { - if (isInsidePath(bundledSkillsDir, cached.path)) continue; - const expectedPath = resolveExpectedBundledSkillPath(cached.path, bundledSkillsDir); - if (!expectedPath) continue; - const key = `${sessionKey}\0${cached.field}\0${cached.path}`; - if (seen.has(key)) continue; - seen.add(key); - findings.push({ sessionKey, field: cached.field, cachedPath: cached.path, expectedPath }); - } - } - return findings; -} - -function replacePathsInSession(session, finding) { - let count = 0; - const jsonEscaped = JSON.stringify(finding.cachedPath).slice(1, -1); - const jsonEscapedExpected = JSON.stringify(finding.expectedPath).slice(1, -1); - const xmlEscaped = finding.cachedPath.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); - const xmlEscapedExpected = finding.expectedPath.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); - - if (finding.field === "skillsSnapshot.prompt") { - const snapshot = session.skillsSnapshot; - if (snapshot && typeof snapshot.prompt === "string") { - let prompt = snapshot.prompt; - const original = prompt; - if (prompt.includes(jsonEscaped)) { count += prompt.split(jsonEscaped).length - 1; prompt = prompt.replaceAll(jsonEscaped, jsonEscapedExpected); } - if (prompt.includes(xmlEscaped)) { count += prompt.split(xmlEscaped).length - 1; prompt = prompt.replaceAll(xmlEscaped, xmlEscapedExpected); } - if (prompt.includes(finding.cachedPath)) { count += prompt.split(finding.cachedPath).length - 1; prompt = prompt.replaceAll(finding.cachedPath, finding.expectedPath); } - if (prompt !== original) snapshot.prompt = prompt; - } - } else if (finding.field === "skillsSnapshot.resolvedSkills") { - const snapshot = session.skillsSnapshot; - if (snapshot && Array.isArray(snapshot.resolvedSkills)) { - for (const entry of snapshot.resolvedSkills) { - if (!entry || typeof entry !== "object") continue; - for (const field of ["filePath", "baseDir"]) { - if (typeof entry[field] !== "string") continue; - let value = entry[field]; - const original = value; - const candidates = [ - { cached: jsonEscaped, expected: jsonEscapedExpected }, - { cached: finding.cachedPath, expected: finding.expectedPath }, - ]; - if (field === "baseDir") { - for (const suffix of ["/SKILL.md", "\\SKILL.md"]) { - if (finding.cachedPath.endsWith(suffix)) { - const cachedDir = finding.cachedPath.slice(0, -suffix.length); - const expectedDir = finding.expectedPath.slice(0, -suffix.length); - candidates.push( - { cached: JSON.stringify(cachedDir).slice(1, -1), expected: JSON.stringify(expectedDir).slice(1, -1) }, - { cached: cachedDir, expected: expectedDir }, - ); - } - } - } - for (const { cached, expected } of candidates) { - if (value.includes(cached)) { count += value.split(cached).length - 1; value = value.replaceAll(cached, expected); } - } - if (value !== original) entry[field] = value; - } - } - } - } else if (finding.field === "systemPromptReport.injectedWorkspaceFiles") { - const report = session.systemPromptReport; - if (report && Array.isArray(report.injectedWorkspaceFiles)) { - for (const entry of report.injectedWorkspaceFiles) { - if (!entry || typeof entry.path !== "string") continue; - let entryPath = entry.path; - const original = entryPath; - for (const { cached, expected } of [ - { cached: jsonEscaped, expected: jsonEscapedExpected }, - { cached: finding.cachedPath, expected: finding.expectedPath }, - ]) { - if (entryPath.includes(cached)) { count += entryPath.split(cached).length - 1; entryPath = entryPath.replaceAll(cached, expected); } - } - if (entryPath !== original) entry.path = entryPath; - } - } - } - return count; -} - -async function main() { - console.log("─".repeat(72)); - console.log("Real Behavior Proof: Doctor Session Snapshot Auto-Repair"); - console.log(`Date: ${new Date().toISOString()}`); - console.log(`Node: ${process.version} | Platform: ${process.platform} ${process.arch}`); - console.log("─".repeat(72)); - console.log(); - - let passed = 0, failed = 0; - - // Scenario 1: WITHOUT repair - console.log("Scenario 1: WITHOUT repair — findings computed but not applied"); - const sessions1 = { - "session-1": { - skillsSnapshot: { - prompt: `Use ${staleRoot}/skills/my-skill/SKILL.md`, - resolvedSkills: [{ id: "my-skill", baseDir: `${staleRoot}/skills/my-skill` }], - }, - }, - }; - const findings1 = findStalePaths(sessions1, liveRoot); - console.log(` Findings computed: ${findings1.length} stale paths`); - console.log(` Repair performed: NO (shouldRepair not enabled)`); - if (findings1.length === 2) { console.log(" PASS"); passed++; } else { console.log(` FAIL: expected 2, got ${findings1.length}`); failed++; } - console.log(); - - // Scenario 2: WITH repair - console.log("Scenario 2: WITH repair — paths replaced correctly"); - const sessions2 = JSON.parse(JSON.stringify(sessions1)); - const file2 = join(testDir, "sessions2.json"); - writeFileSync(file2, JSON.stringify(sessions2, null, 2)); - const backup2 = `${file2}.bak`; - copyFileSync(file2, backup2); - - let totalCount = 0; - for (const finding of findStalePaths(sessions2, liveRoot)) { - totalCount += replacePathsInSession(sessions2[finding.sessionKey], finding); - } - writeFileSync(file2, JSON.stringify(sessions2, null, 2)); - - const remaining2 = findStalePaths(sessions2, liveRoot); - const jsonValid2 = (() => { try { JSON.parse(readFileSync(file2, "utf-8")); return true; } catch { return false; } })(); - console.log(` Paths replaced: ${totalCount}`); - console.log(` Backup created: ${existsSync(backup2)}`); - console.log(` JSON valid: ${jsonValid2}`); - console.log(` Stale paths remaining: ${remaining2.length}`); - if (totalCount === 2 && existsSync(backup2) && jsonValid2 && remaining2.length === 0) { console.log(" PASS"); passed++; } else { console.log(" FAIL"); failed++; } - console.log(); - - // Scenario 3: Windows backslash paths - console.log("Scenario 3: Windows backslash paths — JSON-escaped in file"); - const winStale = "C:\\Users\\user\\.local\\share\\pnpm\\global\\5\\node_modules\\openclaw@2026.5.20_@types+express@5.0.6"; - const winLive = "C:\\Users\\user\\.local\\share\\pnpm\\global\\5\\node_modules\\openclaw@2026.5.20"; - const winSessions = { "session-win": { skillsSnapshot: { prompt: `Use ${winStale}/skills/my-skill/SKILL.md` } } }; - const winFindings = findStalePaths(winSessions, winLive); - let winCount = 0; - for (const f of winFindings) winCount += replacePathsInSession(winSessions[f.sessionKey], f); - const winRepaired = JSON.stringify(winSessions); - const hasWinLive = winRepaired.includes(JSON.stringify(winLive).slice(1, -1)); - const hasWinStale = winRepaired.includes(JSON.stringify(winStale).slice(1, -1)); - console.log(` Repaired contains live root: ${hasWinLive}`); - console.log(` Repaired contains stale root: ${hasWinStale}`); - if (hasWinLive && !hasWinStale && winCount > 0) { console.log(" PASS"); passed++; } else { console.log(" FAIL"); failed++; } - console.log(); - - // Scenario 4: XML-escaped prompt paths with & character - console.log("Scenario 4: XML-escaped prompt paths — entity-encoded in file"); - const xmlStale = "/home/user/projects/my&company/openclaw@2026.5.20_@types+express@5.0.6"; - const xmlLive = "/home/user/projects/my&company/openclaw@2026.5.20"; - const xmlEscapedStale = xmlStale.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); - const xmlSessions = { "session-xml": { skillsSnapshot: { prompt: `Use ${xmlEscapedStale}/skills/my-skill/SKILL.md` } } }; - const xmlFindings = findStalePaths(xmlSessions, xmlLive); - let xmlCount = 0; - for (const f of xmlFindings) xmlCount += replacePathsInSession(xmlSessions[f.sessionKey], f); - const xmlRepaired = JSON.stringify(xmlSessions); - const xmlLiveEscaped = xmlLive.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); - const hasXmlLive = xmlRepaired.includes(xmlLiveEscaped); - const hasXmlStale = xmlRepaired.includes(xmlEscapedStale); - console.log(` Repaired contains live root: ${hasXmlLive}`); - console.log(` Repaired contains stale root: ${hasXmlStale}`); - if (hasXmlLive && !hasXmlStale && xmlCount > 0) { console.log(" PASS"); passed++; } else { console.log(" FAIL"); failed++; } - console.log(); - - // Scenario 5: Idempotent - console.log("Scenario 5: Idempotent — second run finds nothing"); - const sessions5 = JSON.parse(JSON.stringify(sessions1)); - for (const f of findStalePaths(sessions5, liveRoot)) replacePathsInSession(sessions5[f.sessionKey], f); - const secondFindings = findStalePaths(sessions5, liveRoot); - console.log(` Second scan findings: ${secondFindings.length}`); - if (secondFindings.length === 0) { console.log(" PASS"); passed++; } else { console.log(" FAIL"); failed++; } - console.log(); - - // Scenario 6: Scoped replacement — unrelated content preserved - console.log("Scenario 6: Scoped replacement — unrelated content preserved"); - const sessions6 = { - "session-scoped": { - skillsSnapshot: { prompt: `Use ${staleRoot}/skills/my-skill/SKILL.md` }, - transcript: `User mentioned ${staleRoot} in their message. This should NOT be modified.`, - userMessage: `I have a file at ${staleRoot}/some/file.txt that needs attention.`, - }, - "session-other": { - skillsSnapshot: { prompt: `Another skill at ${staleRoot}/skills/other-skill/SKILL.md` }, - }, - }; - let scopedCount = 0; - for (const f of findStalePaths(sessions6, liveRoot)) scopedCount += replacePathsInSession(sessions6[f.sessionKey], f); - const transcriptOk = sessions6["session-scoped"].transcript.includes(staleRoot); - const userMsgOk = sessions6["session-scoped"].userMessage.includes(staleRoot); - const promptOk = !sessions6["session-scoped"].skillsSnapshot.prompt.includes(staleRoot); - const otherOk = !sessions6["session-other"].skillsSnapshot.prompt.includes(staleRoot); - console.log(` Paths replaced: ${scopedCount}`); - console.log(` Transcript preserved: ${transcriptOk}`); - console.log(` User message preserved: ${userMsgOk}`); - console.log(` Prompt repaired: ${promptOk}`); - console.log(` Other session repaired: ${otherOk}`); - if (transcriptOk && userMsgOk && promptOk && otherOk && scopedCount === 2) { console.log(" PASS"); passed++; } else { console.log(" FAIL"); failed++; } - console.log(); - - console.log("─".repeat(72)); - console.log("SUMMARY"); - console.log("─".repeat(72)); - console.log(); - console.log(` Results: ${passed} passed, ${failed} failed`); - console.log(); - console.log(" The repair uses the existing scanner (extractBundledSkillRelativeSegments"); - console.log(" + resolveExpectedBundledSkillPath) to compute expected paths, then applies"); - console.log(" scoped replacement to only modify snapshot metadata fields."); - console.log(" Unrelated content (transcripts, user messages) is preserved unchanged."); - console.log("─".repeat(72)); - - rmSync(testDir, { recursive: true, force: true }); - if (failed > 0) process.exit(1); -} - -main().catch(err => { console.error("Proof script failed:", err); process.exit(1); }); diff --git a/src/commands/doctor-session-snapshots.test.ts b/src/commands/doctor-session-snapshots.test.ts index d9099b767dc..159110c25a3 100644 --- a/src/commands/doctor-session-snapshots.test.ts +++ b/src/commands/doctor-session-snapshots.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { saveSessionStore } from "../config/sessions/store.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import type { Skill } from "../skills/loading/skill-contract.js"; const note = vi.hoisted(() => vi.fn()); @@ -37,6 +38,25 @@ function skillPrompt(location: string): string { ].join("\n"); } +function resolvedSkill(skillPath: string): Skill { + const baseDir = path.dirname(skillPath); + return { + name: "doctor", + description: "Doctor skill", + filePath: skillPath, + baseDir, + source: "bundled", + sourceInfo: { + path: skillPath, + source: "bundled", + scope: "user", + origin: "top-level", + baseDir, + }, + disableModelInvocation: false, + }; +} + async function writeSessionStore( storePath: string, store: Record, @@ -267,23 +287,7 @@ describe("doctor session snapshot stale runtime metadata", () => { skillsSnapshot: { prompt: "", skills: [{ name: "doctor" }], - resolvedSkills: [ - { - name: "doctor", - description: "Doctor skill", - source: "bundled", - filePath: stalePath, - baseDir: path.dirname(stalePath), - sourceInfo: { - path: stalePath, - source: "bundled", - scope: "user", - origin: "top-level", - baseDir: path.dirname(stalePath), - }, - disableModelInvocation: false, - }, - ], + resolvedSkills: [resolvedSkill(stalePath)], }, }), }); @@ -425,7 +429,15 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); it("repairs stale inline prompt paths", async () => { - const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md"); + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); await writeSessionStore(storePath, { "agent:main": sessionEntry({ @@ -436,7 +448,11 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }), }); - await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true }); + await noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); const raw = await fs.readFile(storePath, "utf-8"); expect(raw).not.toContain(stalePath); @@ -447,7 +463,15 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); it("repairs stale promptRef blob paths", async () => { - const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md"); + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); const prompt = `${skillPrompt(stalePath)}\n${"padding\n".repeat(200)}`; await saveSessionStore( @@ -467,7 +491,11 @@ describe("doctor session snapshot repair (shouldRepair)", () => { expect(rawBefore).toContain("promptRef"); expect(rawBefore).not.toContain(stalePath); - await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true }); + await noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); const rawAfter = await fs.readFile(storePath, "utf-8"); expect(rawAfter).toContain("promptRef"); @@ -478,25 +506,31 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); it("repairs stale resolvedSkills filePath and baseDir", async () => { - const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md"); + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); await writeSessionStore(storePath, { "agent:main": sessionEntry({ skillsSnapshot: { prompt: "", skills: [{ name: "doctor" }], - resolvedSkills: [ - { - name: "doctor", - filePath: stalePath, - baseDir: path.dirname(stalePath), - }, - ], + resolvedSkills: [resolvedSkill(stalePath)], }, }), }); - await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true }); + await noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); const raw = await fs.readFile(storePath, "utf-8"); const expectedBaseDir = path.dirname(path.join(bundledSkillsDir, "doctor", "SKILL.md")); @@ -508,8 +542,57 @@ describe("doctor session snapshot repair (shouldRepair)", () => { expect(message).toContain("Repaired"); }); + it("repairs stale resolvedSkills sourceInfo paths after top-level fields are current", async () => { + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); + const currentPath = path.join(bundledSkillsDir, "doctor", "SKILL.md"); + const skill = resolvedSkill(currentPath); + skill.sourceInfo.path = stalePath; + skill.sourceInfo.baseDir = path.dirname(stalePath); + + const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); + await writeSessionStore(storePath, { + "agent:main": sessionEntry({ + skillsSnapshot: { + prompt: "", + skills: [{ name: "doctor" }], + resolvedSkills: [skill], + }, + }), + }); + + await noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); + + const raw = await fs.readFile(storePath, "utf-8"); + expect(raw).toContain(currentPath); + expect(raw).toContain(path.dirname(currentPath)); + expect(raw).not.toContain(path.join(root, "old-runtime")); + expect(note).toHaveBeenCalledTimes(1); + const [message] = note.mock.calls[0] as [string, string]; + expect(message).toContain("Repaired"); + }); + it("preserves sessions with missing promptRef blobs", async () => { - const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md"); + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); await writeSessionStore(storePath, { "agent:healthy": sessionEntry({ @@ -520,13 +603,18 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }), "agent:missing-blob": sessionEntry({ skillsSnapshot: { + prompt: "", promptRef: { version: 1, algorithm: "sha256", hash: "a".repeat(64), bytes: 100 }, skills: [{ name: "doctor" }], }, }), }); - await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true }); + await noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); const raw = await fs.readFile(storePath, "utf-8"); const parsed = JSON.parse(raw); @@ -538,7 +626,15 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); it("handles missing blob gracefully without crashing or reporting false findings", async () => { - const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md"); + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); const prompt = `${skillPrompt(stalePath)}\n${"padding\n".repeat(200)}`; await saveSessionStore( @@ -563,7 +659,11 @@ describe("doctor session snapshot repair (shouldRepair)", () => { // Scanner hydration strips skillsSnapshot for missing blob, // so no findings are reported. Repair should noop gracefully. - await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true }); + await noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); expect(note).not.toHaveBeenCalled(); @@ -574,19 +674,34 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); it("scoped replacement preserves unrelated content", async () => { - const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md"); + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); - await writeSessionStore(storePath, { - "agent:main": sessionEntry({ + const entry = { + ...sessionEntry({ skillsSnapshot: { prompt: skillPrompt(stalePath), skills: [{ name: "doctor" }], }, - transcript: `User mentioned ${stalePath} in their message.`, }), + transcript: `User mentioned ${stalePath} in their message.`, + } satisfies SessionEntry & { transcript: string }; + await writeSessionStore(storePath, { + "agent:main": entry, }); - await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true }); + await noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); const raw = await fs.readFile(storePath, "utf-8"); const parsed = JSON.parse(raw); @@ -596,7 +711,15 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); it("creates backup before repair", async () => { - const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md"); + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); await writeSessionStore(storePath, { "agent:main": sessionEntry({ @@ -607,7 +730,11 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }), }); - await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true }); + await noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); const dir = path.dirname(storePath); const files = await fs.readdir(dir); @@ -619,7 +746,15 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }); it("is idempotent — second repair finds nothing", async () => { - const stalePath = path.join(root, "old-runtime", "node_modules", "openclaw", "skills", "doctor", "SKILL.md"); + const stalePath = path.join( + root, + "old-runtime", + "node_modules", + "openclaw", + "skills", + "doctor", + "SKILL.md", + ); const storePath = path.join(root, "state", "agents", "main", "sessions", "sessions.json"); await writeSessionStore(storePath, { "agent:main": sessionEntry({ @@ -630,11 +765,19 @@ describe("doctor session snapshot repair (shouldRepair)", () => { }), }); - await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true }); + await noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); expect(note).toHaveBeenCalledTimes(1); note.mockClear(); - await noteSessionSnapshotHealth({ storePaths: [storePath], bundledSkillsDir, shouldRepair: true }); + await noteSessionSnapshotHealth({ + storePaths: [storePath], + bundledSkillsDir, + shouldRepair: true, + }); expect(note).not.toHaveBeenCalled(); }); }); diff --git a/src/commands/doctor-session-snapshots.ts b/src/commands/doctor-session-snapshots.ts index 2d93e55788c..6c25c4c47b1 100644 --- a/src/commands/doctor-session-snapshots.ts +++ b/src/commands/doctor-session-snapshots.ts @@ -4,7 +4,10 @@ import path from "node:path"; import { isRecord } from "@openclaw/normalization-core/record-coerce"; import { note } from "../../packages/terminal-core/src/note.js"; import { resolveStateDir } from "../config/paths.js"; -import { hydrateSessionStoreSkillPromptRefs, resolveSessionSkillPromptBlobPath } from "../config/sessions/skill-prompt-blobs.js"; +import { + hydrateSessionStoreSkillPromptRefs, + resolveSessionSkillPromptBlobPath, +} from "../config/sessions/skill-prompt-blobs.js"; import { resolveAllAgentSessionStoreTargetsSync } from "../config/sessions/targets.js"; import type { SessionEntry } from "../config/sessions/types.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; @@ -69,6 +72,14 @@ function collectResolvedSkillPaths(value: unknown): string[] { if (typeof skill.baseDir === "string" && skill.baseDir.trim()) { paths.push(path.join(skill.baseDir.trim(), "SKILL.md")); } + if (isRecord(skill.sourceInfo)) { + if (typeof skill.sourceInfo.path === "string" && skill.sourceInfo.path.trim()) { + paths.push(skill.sourceInfo.path.trim()); + } + if (typeof skill.sourceInfo.baseDir === "string" && skill.sourceInfo.baseDir.trim()) { + paths.push(path.join(skill.sourceInfo.baseDir.trim(), "SKILL.md")); + } + } } return paths; } @@ -277,10 +288,7 @@ function loadSessionStoreForSnapshotScan(storePath: string): Record, + field: string, + ) => { + if (typeof target[field] !== "string") { + return; } - let value = entry[field]; + let value = target[field]; const original = value; const candidates = [ { cached: jsonEscaped, expected: jsonEscapedExpected }, @@ -445,7 +456,10 @@ export async function noteSessionSnapshotHealth(params?: { const cachedDir = finding.cachedPath.slice(0, -suffix.length); const expectedDir = finding.expectedPath.slice(0, -suffix.length); candidates.push( - { cached: JSON.stringify(cachedDir).slice(1, -1), expected: JSON.stringify(expectedDir).slice(1, -1) }, + { + cached: JSON.stringify(cachedDir).slice(1, -1), + expected: JSON.stringify(expectedDir).slice(1, -1), + }, { cached: cachedDir, expected: expectedDir }, ); } @@ -457,10 +471,19 @@ export async function noteSessionSnapshotHealth(params?: { } } if (value !== original) { - entry[field] = value; + target[field] = value; storeCount++; modified = true; } + }; + + for (const field of ["filePath", "baseDir"]) { + replaceResolvedSkillField(entry, field); + } + if (isRecord(entry.sourceInfo)) { + for (const field of ["path", "baseDir"]) { + replaceResolvedSkillField(entry.sourceInfo, field); + } } } } else if (finding.field === "systemPromptReport.injectedWorkspaceFiles") { diff --git a/src/commands/doctor/shared/legacy-config-migrations.channels.ts b/src/commands/doctor/shared/legacy-config-migrations.channels.ts index f7653e4e6e4..ddc035c4819 100644 --- a/src/commands/doctor/shared/legacy-config-migrations.channels.ts +++ b/src/commands/doctor/shared/legacy-config-migrations.channels.ts @@ -481,6 +481,7 @@ function migrateRetiredWebchatChannelConfig(raw: Record, change const gateway = getRecord(raw.gateway) ?? {}; const gatewayWebchat = getRecord(gateway.webchat) ?? {}; const canMoveLegacyTextChunkLimit = + typeof legacyTextChunkLimit === "number" && Number.isInteger(legacyTextChunkLimit) && legacyTextChunkLimit > 0 && legacyTextChunkLimit <= WEBCHAT_CHAT_HISTORY_MAX_CHARS_LIMIT; diff --git a/src/flows/doctor-health-contributions.ts b/src/flows/doctor-health-contributions.ts index cea2ea573df..9f7760f07a4 100644 --- a/src/flows/doctor-health-contributions.ts +++ b/src/flows/doctor-health-contributions.ts @@ -484,7 +484,11 @@ async function runSessionTranscriptsHealth(ctx: DoctorHealthFlowContext): Promis async function runSessionSnapshotsHealth(ctx: DoctorHealthFlowContext): Promise { const { noteSessionSnapshotHealth } = await import("../commands/doctor-session-snapshots.js"); - await noteSessionSnapshotHealth({ cfg: ctx.cfg, env: ctx.env ?? process.env, shouldRepair: ctx.prompter.shouldRepair }); + await noteSessionSnapshotHealth({ + cfg: ctx.cfg, + env: ctx.env ?? process.env, + shouldRepair: ctx.prompter.shouldRepair, + }); } async function runConfigAuditScrubHealth(ctx: DoctorHealthFlowContext): Promise {