From f8ff8c313adeeb89eed5f211040da36ca663c122 Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Mon, 7 Sep 2026 05:15:35 -0400 Subject: [PATCH] Bind interrupted activation recovery to its recorded origin --- .claude-plugin/marketplace.json | 4 +-- CONTRIBUTING.md | 5 ++++ install.md | 2 +- .../claude-code/.claude-plugin/plugin.json | 2 +- plugins/claude-code/blaze-client.mjs | 19 +++++++----- plugins/claude-code/skills/blaze/SKILL.md | 2 +- .../claude-code/skills/blaze/blaze-client.mjs | 19 +++++++----- plugins/client/blaze-client.mjs | 19 +++++++----- plugins/client/lifecycle.test.mjs | 30 +++++++++++++++++++ release.json | 6 ++-- skill.md | 2 +- 11 files changed, 77 insertions(+), 33 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 73511ca..784a897 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -7,14 +7,14 @@ }, "metadata": { "description": "Verified Solution Cards retrieved from an agent-authored conceptual problem statement.", - "version": "0.4.1" + "version": "0.4.2" }, "plugins": [ { "name": "blaze", "source": "./plugins/claude-code", "description": "Retrieves a verified Solution Card after an agent prepares a privacy-bounded conceptual query.", - "version": "0.4.1", + "version": "0.4.2", "keywords": [ "memory", "retrieval", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 239f4ac..88116f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -73,3 +73,8 @@ bytes. Published versions cannot silently change their bytes. The hosted build v its pinned release; local edited trees produce a draft accepted only on loopback origins. Digests from the same HTTPS origin detect corruption and mixed downloads, not a compromised publisher. Manager-owned installations retain their manager's trust boundary. + +Activation journals from 0.4.2 record the service origin before swapping files. +Earlier update journals can recover through their prior ownership record. An older +first-install journal with neither an origin nor a prior record is preserved for +operator recovery; the current caller must not assign it a new origin. diff --git a/install.md b/install.md index 69ff39a..284a579 100644 --- a/install.md +++ b/install.md @@ -1,4 +1,4 @@ -# Install Blaze 0.4.1 +# Install Blaze 0.4.2 Blaze shares verified coding lessons through an explicitly invoked client. Installing it does not authorize uploading prompts, source or transcripts. diff --git a/plugins/claude-code/.claude-plugin/plugin.json b/plugins/claude-code/.claude-plugin/plugin.json index d32e6c1..ed7ecba 100644 --- a/plugins/claude-code/.claude-plugin/plugin.json +++ b/plugins/claude-code/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://anthropic.com/claude-code/plugin.schema.json", "name": "blaze", "displayName": "Blaze", - "version": "0.4.1", + "version": "0.4.2", "description": "Retrieves a verified Solution Card after an agent prepares a privacy-bounded conceptual query.", "author": { "name": "Blaze" diff --git a/plugins/claude-code/blaze-client.mjs b/plugins/claude-code/blaze-client.mjs index e601581..fcd1ea1 100644 --- a/plugins/claude-code/blaze-client.mjs +++ b/plugins/claude-code/blaze-client.mjs @@ -11,7 +11,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/; const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/; const DEFAULT_ORIGIN = "https://blaze.pascal.app"; -export const CLIENT_VERSION = "0.4.1"; +export const CLIENT_VERSION = "0.4.2"; export const CLIENT_CONTRACT = 1; export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"]; const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"]; @@ -645,13 +645,14 @@ async function locked(path, work) { export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) { const paths = toolPaths(tool, home); const invokedRoot = resolve(dirname(helperPath)); - // Hosts can discover another host's global copy. Only an already recorded - // direct bundle (or its interrupted transaction) can establish ownership. + // Hosts can discover another host's global copy. Only a completed direct + // ownership record binds that copy to an origin. The original host must + // recover a first install interrupted before that record was committed. // Credentials and receipts still belong to the invoking tool's state directory. if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) { const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32)); homePath(home,invokedRoot);homePath(home,recordedState); - if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot; + if (pathStat(join(recordedState,"installation.json"))) paths.root = invokedRoot; } const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin); const bundleState = join(home, ".config/blaze/bundles", sha256(resolve(paths.root)).slice(0,32)); @@ -762,10 +763,12 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi } function recover() { const journal = loadRequiredIfPresent(journalPath); if (!journal) return; - exactKeys(journal,new Set(["version","id","release","prior"]),"Activation journal"); - if (journal.version!==1 || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal"); + exactKeys(journal,new Set(journal.version===2 ? ["version","id","origin","release","prior"] : ["version","id","release","prior"]),"Activation journal"); + if (![1,2].includes(journal.version) || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal"); const next = validateRelease(journal.release,base); const prior = validateMetadata(journal.prior); + const recordedOrigin = journal.version===2 ? trustedOrigin(journal.origin) : prior?.origin; + if (recordedOrigin!==base) throw new Error("Interrupted installation has no matching recorded service origin; preserve its state for recovery"); const stage = join(bundleState,"staging",journal.id), backup = join(bundleState,"backups",journal.id); homePath(home,stage);homePath(home,backup);homePath(home,paths.root); if (existsSync(paths.root) && verifyBundle(paths.root,next,false,true)) { @@ -842,7 +845,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi if (syntax.status!==0) throw new Error("Release client failed syntax validation"); const credential = await setup(); migrateReceipts(); - save(journalPath,{version:1,id,release:next,prior}); + save(journalPath,{version:2,id,origin:base,release:next,prior}); if (existsSync(paths.root)) renameSync(paths.root,backup); mkdirSync(dirname(paths.root),{recursive:true,mode:0o700}); renameSync(stage,paths.root); @@ -885,7 +888,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi if (!verifyBundle(paths.root,meta.release) || !verifyBundle(backup,previous)) throw new Error("Rollback bundle was modified"); const id = randomUUID(), stage = join(bundleState,"staging",id); ensurePrivateDir(dirname(stage));renameSync(backup,stage); - save(journalPath,{version:1,id,release:previous,prior:{...meta,pin:previous.version}}); + save(journalPath,{version:2,id,origin:base,release:previous,prior:{...meta,pin:previous.version}}); const currentBackup = join(bundleState,"backups",id);renameSync(paths.root,currentBackup);renameSync(stage,paths.root);recover(); return {version:previous.version,activation:"rolled_back",reload_required:true,pin:previous.version}; }); diff --git a/plugins/claude-code/skills/blaze/SKILL.md b/plugins/claude-code/skills/blaze/SKILL.md index e420d05..93eb205 100644 --- a/plugins/claude-code/skills/blaze/SKILL.md +++ b/plugins/claude-code/skills/blaze/SKILL.md @@ -3,7 +3,7 @@ name: blaze description: Reuse and improve verified coding lessons across agents. Use for a nontrivial debugging or implementation problem where an earlier solution could help, when a Blaze offer or receipt appears, or when the user asks to install, update, contribute to, or link Blaze. Check applicability, verify locally, and close the lookup with an honest outcome and contribution disposition. compatibility: Requires Node.js 20 or later and explicit HTTPS access to the configured Blaze service. Local reminder hooks need no network access. No model provider credentials are needed. metadata: - version: "0.4.1" + version: "0.4.2" --- # Blaze diff --git a/plugins/claude-code/skills/blaze/blaze-client.mjs b/plugins/claude-code/skills/blaze/blaze-client.mjs index e601581..fcd1ea1 100644 --- a/plugins/claude-code/skills/blaze/blaze-client.mjs +++ b/plugins/claude-code/skills/blaze/blaze-client.mjs @@ -11,7 +11,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/; const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/; const DEFAULT_ORIGIN = "https://blaze.pascal.app"; -export const CLIENT_VERSION = "0.4.1"; +export const CLIENT_VERSION = "0.4.2"; export const CLIENT_CONTRACT = 1; export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"]; const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"]; @@ -645,13 +645,14 @@ async function locked(path, work) { export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) { const paths = toolPaths(tool, home); const invokedRoot = resolve(dirname(helperPath)); - // Hosts can discover another host's global copy. Only an already recorded - // direct bundle (or its interrupted transaction) can establish ownership. + // Hosts can discover another host's global copy. Only a completed direct + // ownership record binds that copy to an origin. The original host must + // recover a first install interrupted before that record was committed. // Credentials and receipts still belong to the invoking tool's state directory. if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) { const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32)); homePath(home,invokedRoot);homePath(home,recordedState); - if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot; + if (pathStat(join(recordedState,"installation.json"))) paths.root = invokedRoot; } const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin); const bundleState = join(home, ".config/blaze/bundles", sha256(resolve(paths.root)).slice(0,32)); @@ -762,10 +763,12 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi } function recover() { const journal = loadRequiredIfPresent(journalPath); if (!journal) return; - exactKeys(journal,new Set(["version","id","release","prior"]),"Activation journal"); - if (journal.version!==1 || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal"); + exactKeys(journal,new Set(journal.version===2 ? ["version","id","origin","release","prior"] : ["version","id","release","prior"]),"Activation journal"); + if (![1,2].includes(journal.version) || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal"); const next = validateRelease(journal.release,base); const prior = validateMetadata(journal.prior); + const recordedOrigin = journal.version===2 ? trustedOrigin(journal.origin) : prior?.origin; + if (recordedOrigin!==base) throw new Error("Interrupted installation has no matching recorded service origin; preserve its state for recovery"); const stage = join(bundleState,"staging",journal.id), backup = join(bundleState,"backups",journal.id); homePath(home,stage);homePath(home,backup);homePath(home,paths.root); if (existsSync(paths.root) && verifyBundle(paths.root,next,false,true)) { @@ -842,7 +845,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi if (syntax.status!==0) throw new Error("Release client failed syntax validation"); const credential = await setup(); migrateReceipts(); - save(journalPath,{version:1,id,release:next,prior}); + save(journalPath,{version:2,id,origin:base,release:next,prior}); if (existsSync(paths.root)) renameSync(paths.root,backup); mkdirSync(dirname(paths.root),{recursive:true,mode:0o700}); renameSync(stage,paths.root); @@ -885,7 +888,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi if (!verifyBundle(paths.root,meta.release) || !verifyBundle(backup,previous)) throw new Error("Rollback bundle was modified"); const id = randomUUID(), stage = join(bundleState,"staging",id); ensurePrivateDir(dirname(stage));renameSync(backup,stage); - save(journalPath,{version:1,id,release:previous,prior:{...meta,pin:previous.version}}); + save(journalPath,{version:2,id,origin:base,release:previous,prior:{...meta,pin:previous.version}}); const currentBackup = join(bundleState,"backups",id);renameSync(paths.root,currentBackup);renameSync(stage,paths.root);recover(); return {version:previous.version,activation:"rolled_back",reload_required:true,pin:previous.version}; }); diff --git a/plugins/client/blaze-client.mjs b/plugins/client/blaze-client.mjs index e601581..fcd1ea1 100644 --- a/plugins/client/blaze-client.mjs +++ b/plugins/client/blaze-client.mjs @@ -11,7 +11,7 @@ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/; const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/; const DEFAULT_ORIGIN = "https://blaze.pascal.app"; -export const CLIENT_VERSION = "0.4.1"; +export const CLIENT_VERSION = "0.4.2"; export const CLIENT_CONTRACT = 1; export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"]; const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"]; @@ -645,13 +645,14 @@ async function locked(path, work) { export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) { const paths = toolPaths(tool, home); const invokedRoot = resolve(dirname(helperPath)); - // Hosts can discover another host's global copy. Only an already recorded - // direct bundle (or its interrupted transaction) can establish ownership. + // Hosts can discover another host's global copy. Only a completed direct + // ownership record binds that copy to an origin. The original host must + // recover a first install interrupted before that record was committed. // Credentials and receipts still belong to the invoking tool's state directory. if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) { const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32)); homePath(home,invokedRoot);homePath(home,recordedState); - if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot; + if (pathStat(join(recordedState,"installation.json"))) paths.root = invokedRoot; } const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin); const bundleState = join(home, ".config/blaze/bundles", sha256(resolve(paths.root)).slice(0,32)); @@ -762,10 +763,12 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi } function recover() { const journal = loadRequiredIfPresent(journalPath); if (!journal) return; - exactKeys(journal,new Set(["version","id","release","prior"]),"Activation journal"); - if (journal.version!==1 || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal"); + exactKeys(journal,new Set(journal.version===2 ? ["version","id","origin","release","prior"] : ["version","id","release","prior"]),"Activation journal"); + if (![1,2].includes(journal.version) || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal"); const next = validateRelease(journal.release,base); const prior = validateMetadata(journal.prior); + const recordedOrigin = journal.version===2 ? trustedOrigin(journal.origin) : prior?.origin; + if (recordedOrigin!==base) throw new Error("Interrupted installation has no matching recorded service origin; preserve its state for recovery"); const stage = join(bundleState,"staging",journal.id), backup = join(bundleState,"backups",journal.id); homePath(home,stage);homePath(home,backup);homePath(home,paths.root); if (existsSync(paths.root) && verifyBundle(paths.root,next,false,true)) { @@ -842,7 +845,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi if (syntax.status!==0) throw new Error("Release client failed syntax validation"); const credential = await setup(); migrateReceipts(); - save(journalPath,{version:1,id,release:next,prior}); + save(journalPath,{version:2,id,origin:base,release:next,prior}); if (existsSync(paths.root)) renameSync(paths.root,backup); mkdirSync(dirname(paths.root),{recursive:true,mode:0o700}); renameSync(stage,paths.root); @@ -885,7 +888,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi if (!verifyBundle(paths.root,meta.release) || !verifyBundle(backup,previous)) throw new Error("Rollback bundle was modified"); const id = randomUUID(), stage = join(bundleState,"staging",id); ensurePrivateDir(dirname(stage));renameSync(backup,stage); - save(journalPath,{version:1,id,release:previous,prior:{...meta,pin:previous.version}}); + save(journalPath,{version:2,id,origin:base,release:previous,prior:{...meta,pin:previous.version}}); const currentBackup = join(bundleState,"backups",id);renameSync(paths.root,currentBackup);renameSync(stage,paths.root);recover(); return {version:previous.version,activation:"rolled_back",reload_required:true,pin:previous.version}; }); diff --git a/plugins/client/lifecycle.test.mjs b/plugins/client/lifecycle.test.mjs index ed22a74..f68b3d3 100644 --- a/plugins/client/lifecycle.test.mjs +++ b/plugins/client/lifecycle.test.mjs @@ -193,6 +193,36 @@ test("cross-host discovery preserves origin and symlink checks on recorded owner assert.throws(()=>createLifecycle({...options,tool:"opencode"}).status(),/symbolic links/);assert.equal(requests.length,before); }); +test("an incomplete first install cannot establish cross-host ownership or replace its origin",async t=>{ + const {lifecycle,options,state,control}=await fixture(t);await lifecycle.install(); + rmSync(join(state,"installation.json")); + put(join(state,"transaction.json"),{version:1,id:randomUUID(),release:control.release.manifest,prior:null}); + let calls=0; + const discovered=createLifecycle({...options,tool:"opencode",origin:"https://example.invalid",fetchImpl:async()=>{calls++;return new Response(null,{status:503})}}); + assert.equal((await discovered.update()).installation,"managed_or_unrecorded"); + assert.equal(calls,0);assert.equal(existsSync(join(state,"installation.json")),false); +}); + +test("first-install recovery binds the recorded origin even for hosts with the same default directory",async t=>{ + const {lifecycle,options,state,control,paths}=await fixture(t);await lifecycle.install(); + const credential=readFileSync(paths.token,"utf8");rmSync(join(state,"installation.json")); + put(join(state,"transaction.json"),{version:2,id:randomUUID(),origin:options.origin,release:control.release.manifest,prior:null}); + let calls=0; + const other=createLifecycle({...options,tool:"cursor",origin:"https://example.invalid",fetchImpl:async()=>{calls++;return new Response(null,{status:503})}}); + await assert.rejects(other.update(),/recorded service origin/);assert.equal(calls,0);assert.equal(existsSync(join(state,"installation.json")),false); + assert.equal((await lifecycle.update()).credential,"reused"); + assert.equal(get(join(state,"installation.json")).origin,options.origin);assert.equal(readFileSync(paths.token,"utf8"),credential); +}); + +test("legacy first-install journals without an origin are preserved instead of assigning the caller's origin",async t=>{ + const {lifecycle,state,control,requests}=await fixture(t);await lifecycle.install(); + rmSync(join(state,"installation.json")); + put(join(state,"transaction.json"),{version:1,id:randomUUID(),release:control.release.manifest,prior:null}); + const before=requests.length; + await assert.rejects(lifecycle.update(),/recorded service origin/);assert.equal(requests.length,before); + assert.equal(existsSync(join(state,"installation.json")),false);assert.equal(existsSync(join(state,"transaction.json")),true); +}); + test("intentional requests cache fixed version hints; retired contracts outrank pins",async t=>{ const {lifecycle,paths,options}=await fixture(t);await lifecycle.install();await lifecycle.pin("0.4.0"); let count=0;const client=createClient({origin:options.origin,token:get(paths.token).token,stateDir:join(paths.state,"receipts"),freshnessPath:join(paths.state,"freshness.json"),tool:"codex", diff --git a/release.json b/release.json index 2c825e8..bd61506 100644 --- a/release.json +++ b/release.json @@ -1,7 +1,7 @@ { - "version": "0.4.1", - "created_at": "2026-09-07T08:58:37.955Z", - "updated_at": "2026-09-07T08:58:37.955Z", + "version": "0.4.2", + "created_at": "2026-09-07T09:12:23.208Z", + "updated_at": "2026-09-07T09:12:23.208Z", "client_contract": 1, "minimum_client_contract": 0 } diff --git a/skill.md b/skill.md index e420d05..93eb205 100644 --- a/skill.md +++ b/skill.md @@ -3,7 +3,7 @@ name: blaze description: Reuse and improve verified coding lessons across agents. Use for a nontrivial debugging or implementation problem where an earlier solution could help, when a Blaze offer or receipt appears, or when the user asks to install, update, contribute to, or link Blaze. Check applicability, verify locally, and close the lookup with an honest outcome and contribution disposition. compatibility: Requires Node.js 20 or later and explicit HTTPS access to the configured Blaze service. Local reminder hooks need no network access. No model provider credentials are needed. metadata: - version: "0.4.1" + version: "0.4.2" --- # Blaze