diff --git a/packages/daemon/specs/agents/shared/skills/core/openrig-upgrade/SKILL.md b/packages/daemon/specs/agents/shared/skills/core/openrig-upgrade/SKILL.md index 7e71cdeb3..2666e1a92 100644 --- a/packages/daemon/specs/agents/shared/skills/core/openrig-upgrade/SKILL.md +++ b/packages/daemon/specs/agents/shared/skills/core/openrig-upgrade/SKILL.md @@ -225,11 +225,17 @@ host in front of you. process are gone. If the command returns while either remains, stop: inspect identity and use the host's authorized recovery procedure. Do not blindly signal a PID. -7. Start the target with `rig daemon start` or the host's known wrapper. Verify - daemon status, process path, listener, version, and database integrity. -8. Run the plugin refresh helper in plan mode. Apply safe writes only after the - three roots and classifications make sense; resolve preserved paths one at a - time when required. +7. Before starting the target, account for its startup refresh of managed plugins + and marked global skills. This runtime preserves differing replaced files under + `$OPENRIG_HOME/backups/managed-refresh/` and reports each recovery path; if the + target lacks that preservation, copy those trees first. Start it with + `rig daemon start` or the host's known wrapper. Verify daemon status, process + path, listener, version, and database integrity. Recovery copies retain bytes; + they do not promise custom behaviour remains active after refresh. +8. Run the plugin refresh helper in plan mode. Its plan governs only the helper's + later writes, not startup writes that already happened. Apply safe writes only + after the three roots and classifications make sense; resolve preserved paths + one at a time when required. 9. Verify protected seats with `rig ps --nodes -A --json`, representative `rig capture`, and `rig restore-check` where applicable. `-A` is required: without it the node read is your CURRENT rig only, and a daemon upgrade diff --git a/packages/daemon/src/domain/plugin-vendor-service.ts b/packages/daemon/src/domain/plugin-vendor-service.ts index 2b6ad6a28..5a1e6de7a 100644 --- a/packages/daemon/src/domain/plugin-vendor-service.ts +++ b/packages/daemon/src/domain/plugin-vendor-service.ts @@ -22,11 +22,13 @@ // expected normal-state response until a separate publish authorization. import nodePath from "node:path"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; export interface PluginVendorFs { readFile(path: string): string; writeFile(path: string, content: string): void; + /** Copy raw bytes, failing if the recovery path already exists. */ + copyFileExclusive(source: string, target: string): void; exists(path: string): boolean; mkdirp(path: string): void; listFiles(dir: string): string[]; @@ -168,13 +170,13 @@ export class PluginVendorService { } } + const updates: Array<[string, string]> = this.fs.listFiles(sourceDir).map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]); + if (!this.preserveBeforeRefresh(targetDir, updates)) return; this.fs.mkdirp(targetDir); - const files = this.fs.listFiles(sourceDir); - for (const relPath of files) { + for (const [relPath, content] of updates) { const srcPath = nodePath.join(sourceDir, relPath); const destPath = nodePath.join(targetDir, relPath); - const content = this.fs.readFile(srcPath); // Hash-skip: only write if content differs (idempotent re-runs). Reconcile mode even on // skip — a byte-identical copy staged earlier may still carry the wrong (default) mode. if (this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content)) { @@ -187,6 +189,25 @@ export class PluginVendorService { } } + /** Preserve every replaced file before touching a target, including its version authority. */ + private preserveBeforeRefresh(targetDir: string, updates: Array<[string, string]>): boolean { + const backupDir = nodePath.join(nodePath.dirname(this.userPluginsDir), "backups", "managed-refresh", randomUUID()); + try { + for (const [rel, content] of new Map(updates)) { + const dest = nodePath.join(targetDir, rel); + if (!this.fs.exists(dest) || hashContent(this.fs.readFile(dest)) === hashContent(content)) continue; + const backup = nodePath.join(backupDir, rel); + this.fs.mkdirp(nodePath.dirname(backup)); + this.fs.copyFileExclusive(dest, backup); + this.logger(`[plugin-vendor] preserved ${dest} at ${backup}; custom behaviour may no longer be active after refresh`); + } + return true; + } catch (error) { + this.logger(`[plugin-vendor] preservation failed for ${targetDir}: ${(error as Error).message}; refresh skipped, existing files kept`); + return false; + } + } + /** * Reapply the source file's permission bits to the staged/projected dest. Plain * readFile+writeFile (writeFileSync) creates the dest with the process default mode, @@ -255,11 +276,12 @@ export class PluginVendorService { continue; } } + const updates: Array<[string, string]> = files.map(rel => [rel, this.fs.readFile(nodePath.join(sourceDir, rel))]); + if (!this.preserveBeforeRefresh(targetDir, [...updates, [GLOBAL_VENDOR_VERSION, `${sourceVersion}\n`]])) continue; this.fs.mkdirp(targetDir); - for (const relPath of files) { + for (const [relPath, content] of updates) { const srcPath = nodePath.join(sourceDir, relPath); const destPath = nodePath.join(targetDir, relPath); - const content = this.fs.readFile(srcPath); if ( this.fs.exists(destPath) && hashContent(this.fs.readFile(destPath)) === hashContent(content) diff --git a/packages/daemon/src/startup.ts b/packages/daemon/src/startup.ts index 9ab16a8b9..e932acbe0 100644 --- a/packages/daemon/src/startup.ts +++ b/packages/daemon/src/startup.ts @@ -888,6 +888,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise const realFs = { readFile: (p: string) => fs.readFileSync(p, "utf-8"), writeFile: (p: string, c: string) => fs.writeFileSync(p, c, "utf-8"), + copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL), exists: (p: string) => fs.existsSync(p), mkdirp: (p: string) => fs.mkdirSync(p, { recursive: true }), listFiles: (dir: string) => { diff --git a/packages/daemon/test/plugin-vendor-exec-mode.test.ts b/packages/daemon/test/plugin-vendor-exec-mode.test.ts index 5d1e21b3a..05553d775 100644 --- a/packages/daemon/test/plugin-vendor-exec-mode.test.ts +++ b/packages/daemon/test/plugin-vendor-exec-mode.test.ts @@ -29,6 +29,7 @@ function realVendorFs(): PluginVendorFs { w(dir, ""); return r; }, + copyFileExclusive: (src: string, dest: string) => fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL), statMode: (p) => fs.statSync(p).mode, chmod: (p, m) => fs.chmodSync(p, m), }; @@ -221,3 +222,86 @@ describe("PluginVendorService release-version authority", () => { } }); }); + + +describe("managed refresh preserves recoverable edits", () => { + function upgrade(kind: "plugin" | "plugin skill" | "packaged skill", failBackup = false) { + const base = fs.mkdtempSync(nodePath.join(os.tmpdir(), "vendor-preserve-")); + const assets = nodePath.join(base, "assets"); seedAssets(assets); + const userPlugins = nodePath.join(base, "home", "plugins"); + const globalRoot = nodePath.join(base, "global"); + const plugin = nodePath.join(userPlugins, "openrig-core"); + const source = kind === "plugin" ? nodePath.join(assets, "openrig-core") : plugin; + const target = kind === "plugin" ? plugin : nodePath.join(globalRoot, "compaction-restore"); + const rel = kind === "plugin" ? "skills/compaction-restore/SKILL.md" : "SKILL.md"; + const dest = nodePath.join(target, rel); + const logs: string[] = []; + const ops = realVendorFs(); + const svc = new PluginVendorService({ vendoredAssetsDir: assets, userPluginsDir: userPlugins, fs: ops, httpClient, + logger: (...args) => logs.push(args.map(String).join(" ")) }); + const manifest = (dir: string, version: string) => { + for (const harness of [".claude-plugin", ".codex-plugin"]) { + fs.mkdirSync(nodePath.join(dir, harness), { recursive: true }); + fs.writeFileSync(nodePath.join(dir, harness, "plugin.json"), JSON.stringify({ name: "openrig-core", version })); + } + }; + // Global projection reads a staged plugin; no daemon or native runtime involved. + if (kind !== "plugin") { + fs.mkdirSync(nodePath.join(plugin, "skills/compaction-restore"), { recursive: true }); + fs.writeFileSync(nodePath.join(plugin, "skills/compaction-restore/SKILL.md"), "# compaction-restore\n"); + manifest(plugin, "0.1.0"); + } + fs.mkdirSync(nodePath.dirname(dest), { recursive: true }); + const edited = Buffer.from([0xff, 0, ...Buffer.from("my unique edit\r\n")]); + fs.writeFileSync(dest, edited); + if (kind === "plugin") manifest(target, "0.0.1"); + else fs.writeFileSync(nodePath.join(target, ".openrig-vendor-version"), "0.0.1\n"); + if (failBackup) ops.copyFileExclusive = () => { throw Error("fixture backup denied"); }; + const refresh = () => kind === "plugin" ? svc.ensureVendored("openrig-core") + : kind === "plugin skill" ? svc.ensureSkillGlobally("openrig-core", "compaction-restore", [globalRoot]) + : svc.ensureSkillDirGlobally(nodePath.join(plugin, "skills/compaction-restore"), "compaction-restore", JSON.parse(fs.readFileSync(nodePath.join(source, ".claude-plugin/plugin.json"), "utf8")).version, [globalRoot]); + const backupFiles = () => { + const dir = nodePath.join(base, "home", "backups", "managed-refresh"); + return fs.existsSync(dir) ? ops.listFiles(dir).map(rel => nodePath.join(dir, rel)) : []; + }; + return { base, source, target, dest, edited, logs, manifest, refresh, backupFiles, ops }; + } + + it.each(["plugin", "plugin skill", "packaged skill"] as const)("%s: recovers exact edited bytes after refresh and keeps earlier recovery copies across retries", async kind => { + const f = upgrade(kind); + try { + // Fail the first target write after preservation, then retry the same version with another edit. + const write = f.ops.writeFile; + let failWrite = true; + f.ops.writeFile = (file, content) => { + if (failWrite && file.startsWith(f.target + nodePath.sep)) { failWrite = false; throw Error("fixture interrupted refresh"); } + write(file, content); + }; + await expect(Promise.resolve().then(f.refresh)).rejects.toThrow("fixture interrupted refresh"); + const saved = f.backupFiles().find(file => fs.readFileSync(file).equals(f.edited)); + expect(saved).toBeDefined(); + expect(f.logs.join("\n")).toContain(saved); + expect(f.logs.join("\n")).toContain("may no longer be active"); + const nextEdit = Buffer.from("a second unique edit\n"); fs.writeFileSync(f.dest, nextEdit); + await f.refresh(); + expect(fs.readFileSync(f.dest, "utf8")).toBe("# compaction-restore\n"); + expect(fs.readFileSync(saved!)).toEqual(f.edited); + const second = f.backupFiles().find(file => fs.readFileSync(file).equals(nextEdit)); + expect(second).toBeDefined(); + await f.refresh(); // equal-version retry leaves both recovery copies intact + expect(fs.readFileSync(saved!)).toEqual(f.edited); + expect(fs.readFileSync(second!)).toEqual(nextEdit); + } finally { fs.rmSync(f.base, { recursive: true, force: true }); } + }); + + it.each(["plugin", "plugin skill", "packaged skill"] as const)("%s: a failed preservation leaves all target files and version authority unchanged and warns", async kind => { + const f = upgrade(kind, true); + try { + const before = Object.fromEntries(realVendorFs().listFiles(f.target).map(rel => [rel, fs.readFileSync(nodePath.join(f.target, rel))])); + await expect(Promise.resolve().then(f.refresh)).resolves.toBeUndefined(); + for (const [rel, bytes] of Object.entries(before)) expect(fs.readFileSync(nodePath.join(f.target, rel))).toEqual(bytes); + expect(f.logs.join("\n")).toContain("fixture backup denied"); + expect(f.logs.join("\n")).toContain("refresh skipped"); + } finally { fs.rmSync(f.base, { recursive: true, force: true }); } + }); +}); diff --git a/packages/daemon/test/plugin-vendor-service.test.ts b/packages/daemon/test/plugin-vendor-service.test.ts index 9dd3eef68..b2c64af01 100644 --- a/packages/daemon/test/plugin-vendor-service.test.ts +++ b/packages/daemon/test/plugin-vendor-service.test.ts @@ -25,6 +25,10 @@ function mockFs(initialFiles?: Record) { return { readFile: (p: string) => { if (p in store) return store[p]!; throw new Error(`Not found: ${p}`); }, writeFile: (p: string, c: string) => { store[p] = c; }, + copyFileExclusive: (src: string, dest: string) => { + if (dest in store) throw Error(`Recovery path exists: ${dest}`); + store[dest] = store[src]!; + }, exists: (p: string) => p in store || Object.keys(store).some((k) => k.startsWith(p + "/")), mkdirp: () => {}, listFiles: (dir: string) => Object.keys(store).filter((k) => k.startsWith(dir + "/")).map((k) => k.slice(dir.length + 1)), diff --git a/scripts/skill-edge-digests.generated.json b/scripts/skill-edge-digests.generated.json index 5cf859004..9726b0849 100644 --- a/scripts/skill-edge-digests.generated.json +++ b/scripts/skill-edge-digests.generated.json @@ -13,7 +13,7 @@ "core/openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da", "core/openrig-software-factory/SKILL.md": "7a57815f3c6865d5cc42c063cccdca998d6ee100a82812362d0c81f314465b77", "core/openrig-software-factory/references/worked-example.md": "409680b7e96e1b5725a68fd7e96da0a4c908fec88a77599d39f574d9e64bcc88", - "core/openrig-upgrade/SKILL.md": "0ddbe16d8c93cd093e5fd3bd11a4323479db46a6d49ae9906df940b699f6e297", + "core/openrig-upgrade/SKILL.md": "5653ccb547ef22ecb4afe3d9a376e522997d1ebf44639690a875cdc5d7447951", "core/openrig-upgrade/scripts/backup-sqlite.mjs": "52fe3015a0399986b586b6603e0664f61136d2210611b1638110a7b6b06e38b4", "core/openrig-upgrade/scripts/inspect-upgrade.mjs": "0f9239f3f8ad75885e985433576ffba9cf155dd8616801dd73d60bf3e9990a2a", "core/openrig-upgrade/scripts/migrate-telemetry-state-0.5.9.mjs": "3d7bdb43ce0618cf1d939e08c3f91531d866ac5b6fec5c4c3e4308d61e2bfc9f", @@ -112,7 +112,7 @@ "core/openrig-skills/SKILL.md": "bdd6594ae1108455103239d39694c865cc40d3f7d9f24cd3566a18397df270da", "core/openrig-software-factory/SKILL.md": "7a57815f3c6865d5cc42c063cccdca998d6ee100a82812362d0c81f314465b77", "core/openrig-software-factory/references/worked-example.md": "409680b7e96e1b5725a68fd7e96da0a4c908fec88a77599d39f574d9e64bcc88", - "core/openrig-upgrade/SKILL.md": "0ddbe16d8c93cd093e5fd3bd11a4323479db46a6d49ae9906df940b699f6e297", + "core/openrig-upgrade/SKILL.md": "5653ccb547ef22ecb4afe3d9a376e522997d1ebf44639690a875cdc5d7447951", "core/openrig-upgrade/scripts/backup-sqlite.mjs": "52fe3015a0399986b586b6603e0664f61136d2210611b1638110a7b6b06e38b4", "core/openrig-upgrade/scripts/inspect-upgrade.mjs": "0f9239f3f8ad75885e985433576ffba9cf155dd8616801dd73d60bf3e9990a2a", "core/openrig-upgrade/scripts/migrate-telemetry-state-0.5.9.mjs": "3d7bdb43ce0618cf1d939e08c3f91531d866ac5b6fec5c4c3e4308d61e2bfc9f", diff --git a/skills/_canonical/core/openrig-upgrade/SKILL.md b/skills/_canonical/core/openrig-upgrade/SKILL.md index 7e71cdeb3..2666e1a92 100644 --- a/skills/_canonical/core/openrig-upgrade/SKILL.md +++ b/skills/_canonical/core/openrig-upgrade/SKILL.md @@ -225,11 +225,17 @@ host in front of you. process are gone. If the command returns while either remains, stop: inspect identity and use the host's authorized recovery procedure. Do not blindly signal a PID. -7. Start the target with `rig daemon start` or the host's known wrapper. Verify - daemon status, process path, listener, version, and database integrity. -8. Run the plugin refresh helper in plan mode. Apply safe writes only after the - three roots and classifications make sense; resolve preserved paths one at a - time when required. +7. Before starting the target, account for its startup refresh of managed plugins + and marked global skills. This runtime preserves differing replaced files under + `$OPENRIG_HOME/backups/managed-refresh/` and reports each recovery path; if the + target lacks that preservation, copy those trees first. Start it with + `rig daemon start` or the host's known wrapper. Verify daemon status, process + path, listener, version, and database integrity. Recovery copies retain bytes; + they do not promise custom behaviour remains active after refresh. +8. Run the plugin refresh helper in plan mode. Its plan governs only the helper's + later writes, not startup writes that already happened. Apply safe writes only + after the three roots and classifications make sense; resolve preserved paths + one at a time when required. 9. Verify protected seats with `rig ps --nodes -A --json`, representative `rig capture`, and `rig restore-check` where applicable. `-A` is required: without it the node read is your CURRENT rig only, and a daemon upgrade