From 98486d3a55a04eb4aa91240523ed55d0ce721e7a Mon Sep 17 00:00:00 2001 From: artale Date: Thu, 25 Jun 2026 01:11:04 +0200 Subject: [PATCH] fix: require fresh structured RSI canary proof --- src/fable5/rsi-reconcile.test.ts | 43 ++++++++++++++++++++--------- src/fable5/rsi-reconcile.ts | 46 ++++++++++++++++++++++++-------- src/fable5/verify-cycle.test.ts | 44 +++++++++++++++++++++--------- src/fable5/verify-cycle.ts | 2 +- 4 files changed, 99 insertions(+), 36 deletions(-) diff --git a/src/fable5/rsi-reconcile.test.ts b/src/fable5/rsi-reconcile.test.ts index a07e24b..d7b0546 100644 --- a/src/fable5/rsi-reconcile.test.ts +++ b/src/fable5/rsi-reconcile.test.ts @@ -16,6 +16,23 @@ function tmpFile(): string { return path.join(root, "receipt.json"); } +function canaryProof(createdAt: string) { + return { + schema: "fable.rsi.canary-proof.v1", + created_at: createdAt, + skill: "rsi_canary", + degraded_before: true, + status_before: 1, + auto_patch: { + command: "python3 /tmp/skill_health.py --auto-patch", + status: 0, + evidence: "auto-patching 1 degraded skills\nAll patched successfully", + }, + recovered_after: true, + status_after: 0, + }; +} + function runner(overrides: Record = {}) { return (_command: string, args: string[]) => { const script = args.at(-1) ?? ""; @@ -46,25 +63,27 @@ describe("rsi reconcile", () => { expect(receipt.decision).toBe("healthy"); }); - it("marks healthy when a fresh canary proof receipt exists", () => { + it("marks healthy when a fresh structured canary proof receipt exists", () => { const proof = tmpFile(); - fs.writeFileSync(proof, [ - "rsi_canary: degraded", - "STATUS_BEFORE:1", - "auto-patching 1 degraded skills", - "All patched successfully", - "rsi_canary: recovered", - "STATUS_PATCH:0", - "STATUS_AFTER:0", - ].join("\n")); + fs.writeFileSync(proof, JSON.stringify(canaryProof("2026-06-18T00:00:00.000Z"))); - expect(verifyCanaryProof(proof)).toBe(true); - const receipt = reconcileRsi({ host: "example", canaryProof: proof, runner: runner() }); + expect(verifyCanaryProof(proof, new Date("2026-06-18T01:00:00.000Z"))).toEqual({ ok: true }); + const receipt = reconcileRsi({ host: "example", canaryProof: proof, runner: runner(), now: new Date("2026-06-18T01:00:00.000Z") }); expect(receipt.facts.auto_patch_proven).toBe(true); expect(receipt.facts.canary_proof).toBe(proof); expect(receipt.decision).toBe("healthy"); }); + it("rejects stale or unstructured canary proof", () => { + const old = tmpFile(); + fs.writeFileSync(old, JSON.stringify(canaryProof("2026-06-16T00:00:00.000Z"))); + expect(verifyCanaryProof(old, new Date("2026-06-18T01:00:00.000Z"))).toEqual({ ok: false, reason: "stale" }); + + const text = tmpFile(); + fs.writeFileSync(text, "rsi_canary: degraded\nSTATUS_BEFORE:1\n"); + expect(verifyCanaryProof(text, new Date("2026-06-18T01:00:00.000Z"))).toEqual({ ok: false, reason: "invalid_json" }); + }); + it("keeps healthy when only cron naming drifts", () => { const receipt = reconcileRsi({ host: "example", runner: runner({ "skill_health.py|rsi-diagnosis": { status: 1, stdout: "" } }) }); diff --git a/src/fable5/rsi-reconcile.ts b/src/fable5/rsi-reconcile.ts index 2d8f543..1507e09 100644 --- a/src/fable5/rsi-reconcile.ts +++ b/src/fable5/rsi-reconcile.ts @@ -11,6 +11,17 @@ export interface RsiReconcileOptions { runner?: (command: string, args: string[]) => { status: number | null; stdout: string; stderr: string }; } +export interface RsiCanaryProofReceipt { + schema: "fable.rsi.canary-proof.v1"; + created_at: string; + skill: string; + degraded_before: true; + status_before: 1; + auto_patch: { command: string; status: 0; evidence: string }; + recovered_after: true; + status_after: 0; +} + export interface RsiReconcileReceipt { created_at: string; host: string; @@ -39,7 +50,7 @@ export function reconcileRsi(opts: RsiReconcileOptions): RsiReconcileReceipt { check("deploy_route", port, opts.host, "code=\"$(curl -sS -m 5 -o /dev/null -w '%{http_code}' -X POST http://127.0.0.1:8099/deploy)\" && (test \"$code\" = \"401\" || test \"$code\" = \"403\") && echo present", runner), ]; const latest = checks.find((c) => c.name === "latest_rsi")?.stdout ?? ""; - const canaryProofOk = opts.canaryProof ? verifyCanaryProof(opts.canaryProof) : false; + const canaryProofOk = opts.canaryProof ? verifyCanaryProof(opts.canaryProof, opts.now).ok : false; const facts = { cron_present: isOk("cron", checks), skill_health_present: isOk("skill_health", checks), @@ -63,16 +74,29 @@ export function reconcileRsi(opts: RsiReconcileOptions): RsiReconcileReceipt { return receipt; } -export function verifyCanaryProof(file: string): boolean { - if (!fs.existsSync(file)) return false; - const text = fs.readFileSync(file, "utf-8"); - return /rsi_canary: degraded/.test(text) - && /STATUS_BEFORE:1/.test(text) - && /auto-patching 1 degraded skills/.test(text) - && /All patched successfully/.test(text) - && /rsi_canary: recovered/.test(text) - && /STATUS_PATCH:0/.test(text) - && /STATUS_AFTER:0/.test(text); +export function verifyCanaryProof(file: string, now = new Date()): { ok: boolean; reason?: string } { + if (!fs.existsSync(file)) return { ok: false, reason: "missing" }; + let receipt: RsiCanaryProofReceipt; + try { + receipt = JSON.parse(fs.readFileSync(file, "utf-8")) as RsiCanaryProofReceipt; + } catch { + return { ok: false, reason: "invalid_json" }; + } + const created = Date.parse(receipt.created_at); + if (!Number.isFinite(created)) return { ok: false, reason: "invalid_created_at" }; + const ageMs = Math.abs(now.getTime() - created); + if (ageMs > 24 * 60 * 60 * 1000) return { ok: false, reason: "stale" }; + const ok = receipt.schema === "fable.rsi.canary-proof.v1" + && receipt.skill === "rsi_canary" + && receipt.degraded_before === true + && receipt.status_before === 1 + && receipt.auto_patch?.status === 0 + && /skill_health.py --auto-patch/.test(receipt.auto_patch.command) + && /auto-patching 1 degraded skills/.test(receipt.auto_patch.evidence) + && /All patched successfully/.test(receipt.auto_patch.evidence) + && receipt.recovered_after === true + && receipt.status_after === 0; + return ok ? { ok: true } : { ok: false, reason: "missing_required_evidence" }; } export function writeRsiReconcileReceipt(file: string, receipt: RsiReconcileReceipt): void { diff --git a/src/fable5/verify-cycle.test.ts b/src/fable5/verify-cycle.test.ts index 1df1750..31fda4d 100644 --- a/src/fable5/verify-cycle.test.ts +++ b/src/fable5/verify-cycle.test.ts @@ -26,6 +26,23 @@ function runner(command: string, args: string[]) { return { status: 1, stdout: "", stderr: `unexpected: ${full}` }; } +function canaryProof(createdAt: string) { + return { + schema: "fable.rsi.canary-proof.v1", + created_at: createdAt, + skill: "rsi_canary", + degraded_before: true, + status_before: 1, + auto_patch: { + command: "python3 /tmp/skill_health.py --auto-patch", + status: 0, + evidence: "auto-patching 1 degraded skills\nAll patched successfully", + }, + recovered_after: true, + status_after: 0, + }; +} + describe("verifyCycle", () => { it("writes a passing receipt with 8099 deploy evidence", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "verify-cycle-")); @@ -51,16 +68,8 @@ describe("verifyCycle", () => { it("passes reconciled stale factory only with explicit opt-in and canary proof", async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "verify-cycle-proof-")); - const proof = path.join(dir, "canary.txt"); - fs.writeFileSync(proof, [ - "rsi_canary: degraded", - "STATUS_BEFORE:1", - "auto-patching 1 degraded skills", - "All patched successfully", - "rsi_canary: recovered", - "STATUS_PATCH:0", - "STATUS_AFTER:0", - ].join("\n")); + const proof = path.join(dir, "canary.json"); + fs.writeFileSync(proof, JSON.stringify(canaryProof("2026-06-19T00:00:00.000Z"))); const staleFactory = { ...factoryOk, factory: { @@ -72,16 +81,27 @@ describe("verifyCycle", () => { }, }; - const blocked = await verifyCycle({ host: "127.0.0.1", skill: "rsi_canary", allowDirty: true, canaryProof: proof, factoryCheck: async () => staleFactory, runner, rsiRunner: runner }); + const blocked = await verifyCycle({ host: "127.0.0.1", skill: "rsi_canary", allowDirty: true, canaryProof: proof, now: new Date("2026-06-19T01:00:00.000Z"), factoryCheck: async () => staleFactory, runner, rsiRunner: runner }); expect(blocked.result).toBe("failed"); expect(blocked.factory_ready).toBe(false); - const allowed = await verifyCycle({ host: "127.0.0.1", skill: "rsi_canary", allowDirty: true, allowReconciledStaleFactory: true, canaryProof: proof, factoryCheck: async () => staleFactory, runner, rsiRunner: runner }); + const allowed = await verifyCycle({ host: "127.0.0.1", skill: "rsi_canary", allowDirty: true, allowReconciledStaleFactory: true, canaryProof: proof, now: new Date("2026-06-19T01:00:00.000Z"), factoryCheck: async () => staleFactory, runner, rsiRunner: runner }); expect(allowed.result).toBe("passed"); expect(allowed.factory_ready).toBe(true); expect(allowed.rsi.facts.canary_proof).toBe(proof); }); + it("fails when RSI health is unproven", async () => { + const unprovenRunner = (command: string, args: string[]) => { + const full = [command, ...args].join(" "); + if (full.includes("tail -160 /tmp/rsi-diagnosis.log")) return { status: 0, stdout: "RSI: 65/65 (100%)\nAll healthy — ZTE idle\n", stderr: "" }; + return runner(command, args); + }; + const receipt = await verifyCycle({ host: "127.0.0.1", skill: "rsi_canary", allowDirty: true, factoryCheck: async () => factoryOk, runner, rsiRunner: unprovenRunner }); + expect(receipt.rsi.decision).toBe("healthy_but_autopatch_unproven"); + expect(receipt.result).toBe("failed"); + }); + it("fails dirty worktrees unless explicitly allowed", async () => { const receipt = await verifyCycle({ host: "127.0.0.1", skill: "rsi_canary", factoryCheck: async () => factoryOk, runner, rsiRunner: runner }); expect(receipt.result).toBe("failed"); diff --git a/src/fable5/verify-cycle.ts b/src/fable5/verify-cycle.ts index c1143eb..cce2b09 100644 --- a/src/fable5/verify-cycle.ts +++ b/src/fable5/verify-cycle.ts @@ -50,7 +50,7 @@ export async function verifyCycle(opts: VerifyCycleOptions): Promise c.name === "changed_files")?.stdout ?? "").split(/\r?\n/).filter(Boolean); const cleanEnough = opts.allowDirty || changed.length === 0; const factoryReady = factoryGateReady(factory) || (opts.allowReconciledStaleFactory === true && factoryStaleButReconciled(factory)); - const passed = cleanEnough && factoryReady && rsi.decision !== "degraded" && commands.every((c) => c.status === 0); + const passed = cleanEnough && factoryReady && rsi.decision === "healthy" && commands.every((c) => c.status === 0); const receipt: VerifyCycleReceipt = { created_at: created, commit_sha: commit,