Skip to content

Commit f8ff8c3

Browse files
committed
Bind interrupted activation recovery to its recorded origin
1 parent 159fc93 commit f8ff8c3

11 files changed

Lines changed: 77 additions & 33 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,14 @@
77
},
88
"metadata": {
99
"description": "Verified Solution Cards retrieved from an agent-authored conceptual problem statement.",
10-
"version": "0.4.1"
10+
"version": "0.4.2"
1111
},
1212
"plugins": [
1313
{
1414
"name": "blaze",
1515
"source": "./plugins/claude-code",
1616
"description": "Retrieves a verified Solution Card after an agent prepares a privacy-bounded conceptual query.",
17-
"version": "0.4.1",
17+
"version": "0.4.2",
1818
"keywords": [
1919
"memory",
2020
"retrieval",

CONTRIBUTING.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,3 +73,8 @@ bytes. Published versions cannot silently change their bytes. The hosted build v
7373
its pinned release; local edited trees produce a draft accepted only on loopback origins.
7474
Digests from the same HTTPS origin detect corruption and mixed downloads, not a
7575
compromised publisher. Manager-owned installations retain their manager's trust boundary.
76+
77+
Activation journals from 0.4.2 record the service origin before swapping files.
78+
Earlier update journals can recover through their prior ownership record. An older
79+
first-install journal with neither an origin nor a prior record is preserved for
80+
operator recovery; the current caller must not assign it a new origin.

install.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Install Blaze 0.4.1
1+
# Install Blaze 0.4.2
22

33
Blaze shares verified coding lessons through an explicitly invoked client.
44
Installing it does not authorize uploading prompts, source or transcripts.

plugins/claude-code/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"$schema": "https://anthropic.com/claude-code/plugin.schema.json",
33
"name": "blaze",
44
"displayName": "Blaze",
5-
"version": "0.4.1",
5+
"version": "0.4.2",
66
"description": "Retrieves a verified Solution Card after an agent prepares a privacy-bounded conceptual query.",
77
"author": {
88
"name": "Blaze"

plugins/claude-code/blaze-client.mjs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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;
1111
const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/;
1212
const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/;
1313
const DEFAULT_ORIGIN = "https://blaze.pascal.app";
14-
export const CLIENT_VERSION = "0.4.1";
14+
export const CLIENT_VERSION = "0.4.2";
1515
export const CLIENT_CONTRACT = 1;
1616
export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"];
1717
const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"];
@@ -645,13 +645,14 @@ async function locked(path, work) {
645645
export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) {
646646
const paths = toolPaths(tool, home);
647647
const invokedRoot = resolve(dirname(helperPath));
648-
// Hosts can discover another host's global copy. Only an already recorded
649-
// direct bundle (or its interrupted transaction) can establish ownership.
648+
// Hosts can discover another host's global copy. Only a completed direct
649+
// ownership record binds that copy to an origin. The original host must
650+
// recover a first install interrupted before that record was committed.
650651
// Credentials and receipts still belong to the invoking tool's state directory.
651652
if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) {
652653
const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32));
653654
homePath(home,invokedRoot);homePath(home,recordedState);
654-
if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot;
655+
if (pathStat(join(recordedState,"installation.json"))) paths.root = invokedRoot;
655656
}
656657
const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin);
657658
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
762763
}
763764
function recover() {
764765
const journal = loadRequiredIfPresent(journalPath); if (!journal) return;
765-
exactKeys(journal,new Set(["version","id","release","prior"]),"Activation journal");
766-
if (journal.version!==1 || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal");
766+
exactKeys(journal,new Set(journal.version===2 ? ["version","id","origin","release","prior"] : ["version","id","release","prior"]),"Activation journal");
767+
if (![1,2].includes(journal.version) || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal");
767768
const next = validateRelease(journal.release,base);
768769
const prior = validateMetadata(journal.prior);
770+
const recordedOrigin = journal.version===2 ? trustedOrigin(journal.origin) : prior?.origin;
771+
if (recordedOrigin!==base) throw new Error("Interrupted installation has no matching recorded service origin; preserve its state for recovery");
769772
const stage = join(bundleState,"staging",journal.id), backup = join(bundleState,"backups",journal.id);
770773
homePath(home,stage);homePath(home,backup);homePath(home,paths.root);
771774
if (existsSync(paths.root) && verifyBundle(paths.root,next,false,true)) {
@@ -842,7 +845,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi
842845
if (syntax.status!==0) throw new Error("Release client failed syntax validation");
843846
const credential = await setup();
844847
migrateReceipts();
845-
save(journalPath,{version:1,id,release:next,prior});
848+
save(journalPath,{version:2,id,origin:base,release:next,prior});
846849
if (existsSync(paths.root)) renameSync(paths.root,backup);
847850
mkdirSync(dirname(paths.root),{recursive:true,mode:0o700});
848851
renameSync(stage,paths.root);
@@ -885,7 +888,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi
885888
if (!verifyBundle(paths.root,meta.release) || !verifyBundle(backup,previous)) throw new Error("Rollback bundle was modified");
886889
const id = randomUUID(), stage = join(bundleState,"staging",id);
887890
ensurePrivateDir(dirname(stage));renameSync(backup,stage);
888-
save(journalPath,{version:1,id,release:previous,prior:{...meta,pin:previous.version}});
891+
save(journalPath,{version:2,id,origin:base,release:previous,prior:{...meta,pin:previous.version}});
889892
const currentBackup = join(bundleState,"backups",id);renameSync(paths.root,currentBackup);renameSync(stage,paths.root);recover();
890893
return {version:previous.version,activation:"rolled_back",reload_required:true,pin:previous.version};
891894
});

plugins/claude-code/skills/blaze/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ name: blaze
33
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.
44
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.
55
metadata:
6-
version: "0.4.1"
6+
version: "0.4.2"
77
---
88

99
# Blaze

plugins/claude-code/skills/blaze/blaze-client.mjs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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;
1111
const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/;
1212
const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/;
1313
const DEFAULT_ORIGIN = "https://blaze.pascal.app";
14-
export const CLIENT_VERSION = "0.4.1";
14+
export const CLIENT_VERSION = "0.4.2";
1515
export const CLIENT_CONTRACT = 1;
1616
export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"];
1717
const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"];
@@ -645,13 +645,14 @@ async function locked(path, work) {
645645
export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) {
646646
const paths = toolPaths(tool, home);
647647
const invokedRoot = resolve(dirname(helperPath));
648-
// Hosts can discover another host's global copy. Only an already recorded
649-
// direct bundle (or its interrupted transaction) can establish ownership.
648+
// Hosts can discover another host's global copy. Only a completed direct
649+
// ownership record binds that copy to an origin. The original host must
650+
// recover a first install interrupted before that record was committed.
650651
// Credentials and receipts still belong to the invoking tool's state directory.
651652
if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) {
652653
const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32));
653654
homePath(home,invokedRoot);homePath(home,recordedState);
654-
if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot;
655+
if (pathStat(join(recordedState,"installation.json"))) paths.root = invokedRoot;
655656
}
656657
const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin);
657658
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
762763
}
763764
function recover() {
764765
const journal = loadRequiredIfPresent(journalPath); if (!journal) return;
765-
exactKeys(journal,new Set(["version","id","release","prior"]),"Activation journal");
766-
if (journal.version!==1 || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal");
766+
exactKeys(journal,new Set(journal.version===2 ? ["version","id","origin","release","prior"] : ["version","id","release","prior"]),"Activation journal");
767+
if (![1,2].includes(journal.version) || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal");
767768
const next = validateRelease(journal.release,base);
768769
const prior = validateMetadata(journal.prior);
770+
const recordedOrigin = journal.version===2 ? trustedOrigin(journal.origin) : prior?.origin;
771+
if (recordedOrigin!==base) throw new Error("Interrupted installation has no matching recorded service origin; preserve its state for recovery");
769772
const stage = join(bundleState,"staging",journal.id), backup = join(bundleState,"backups",journal.id);
770773
homePath(home,stage);homePath(home,backup);homePath(home,paths.root);
771774
if (existsSync(paths.root) && verifyBundle(paths.root,next,false,true)) {
@@ -842,7 +845,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi
842845
if (syntax.status!==0) throw new Error("Release client failed syntax validation");
843846
const credential = await setup();
844847
migrateReceipts();
845-
save(journalPath,{version:1,id,release:next,prior});
848+
save(journalPath,{version:2,id,origin:base,release:next,prior});
846849
if (existsSync(paths.root)) renameSync(paths.root,backup);
847850
mkdirSync(dirname(paths.root),{recursive:true,mode:0o700});
848851
renameSync(stage,paths.root);
@@ -885,7 +888,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi
885888
if (!verifyBundle(paths.root,meta.release) || !verifyBundle(backup,previous)) throw new Error("Rollback bundle was modified");
886889
const id = randomUUID(), stage = join(bundleState,"staging",id);
887890
ensurePrivateDir(dirname(stage));renameSync(backup,stage);
888-
save(journalPath,{version:1,id,release:previous,prior:{...meta,pin:previous.version}});
891+
save(journalPath,{version:2,id,origin:base,release:previous,prior:{...meta,pin:previous.version}});
889892
const currentBackup = join(bundleState,"backups",id);renameSync(paths.root,currentBackup);renameSync(stage,paths.root);recover();
890893
return {version:previous.version,activation:"rolled_back",reload_required:true,pin:previous.version};
891894
});

plugins/client/blaze-client.mjs

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -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;
1111
const TOKEN = /^blz_[A-Za-z0-9_-]{43}$/;
1212
const CARD_ID = /^[a-z0-9][a-z0-9-]{2,62}$/;
1313
const DEFAULT_ORIGIN = "https://blaze.pascal.app";
14-
export const CLIENT_VERSION = "0.4.1";
14+
export const CLIENT_VERSION = "0.4.2";
1515
export const CLIENT_CONTRACT = 1;
1616
export const CLIENT_TOOLS = ["claude", "codex", "opencode", "cursor", "openclaw", "agent"];
1717
const RELEASE_FILES = ["SKILL.md", "blaze-client.mjs"];
@@ -645,13 +645,14 @@ async function locked(path, work) {
645645
export function createLifecycle({tool, home = homedir(), origin, helperPath = fileURLToPath(import.meta.url), fetchImpl = fetch}) {
646646
const paths = toolPaths(tool, home);
647647
const invokedRoot = resolve(dirname(helperPath));
648-
// Hosts can discover another host's global copy. Only an already recorded
649-
// direct bundle (or its interrupted transaction) can establish ownership.
648+
// Hosts can discover another host's global copy. Only a completed direct
649+
// ownership record binds that copy to an origin. The original host must
650+
// recover a first install interrupted before that record was committed.
650651
// Credentials and receipts still belong to the invoking tool's state directory.
651652
if (invokedRoot !== resolve(paths.root) && CLIENT_TOOLS.some(name => resolve(toolPaths(name,home).root) === invokedRoot)) {
652653
const recordedState = join(home,".config/blaze/bundles",sha256(invokedRoot).slice(0,32));
653654
homePath(home,invokedRoot);homePath(home,recordedState);
654-
if (pathStat(join(recordedState,"installation.json")) || pathStat(join(recordedState,"transaction.json"))) paths.root = invokedRoot;
655+
if (pathStat(join(recordedState,"installation.json"))) paths.root = invokedRoot;
655656
}
656657
const base = trustedOrigin(origin ?? readToolCredential(tool, home, false).origin);
657658
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
762763
}
763764
function recover() {
764765
const journal = loadRequiredIfPresent(journalPath); if (!journal) return;
765-
exactKeys(journal,new Set(["version","id","release","prior"]),"Activation journal");
766-
if (journal.version!==1 || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal");
766+
exactKeys(journal,new Set(journal.version===2 ? ["version","id","origin","release","prior"] : ["version","id","release","prior"]),"Activation journal");
767+
if (![1,2].includes(journal.version) || !UUID.test(journal.id ?? "")) throw new Error("Invalid activation journal");
767768
const next = validateRelease(journal.release,base);
768769
const prior = validateMetadata(journal.prior);
770+
const recordedOrigin = journal.version===2 ? trustedOrigin(journal.origin) : prior?.origin;
771+
if (recordedOrigin!==base) throw new Error("Interrupted installation has no matching recorded service origin; preserve its state for recovery");
769772
const stage = join(bundleState,"staging",journal.id), backup = join(bundleState,"backups",journal.id);
770773
homePath(home,stage);homePath(home,backup);homePath(home,paths.root);
771774
if (existsSync(paths.root) && verifyBundle(paths.root,next,false,true)) {
@@ -842,7 +845,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi
842845
if (syntax.status!==0) throw new Error("Release client failed syntax validation");
843846
const credential = await setup();
844847
migrateReceipts();
845-
save(journalPath,{version:1,id,release:next,prior});
848+
save(journalPath,{version:2,id,origin:base,release:next,prior});
846849
if (existsSync(paths.root)) renameSync(paths.root,backup);
847850
mkdirSync(dirname(paths.root),{recursive:true,mode:0o700});
848851
renameSync(stage,paths.root);
@@ -885,7 +888,7 @@ export function createLifecycle({tool, home = homedir(), origin, helperPath = fi
885888
if (!verifyBundle(paths.root,meta.release) || !verifyBundle(backup,previous)) throw new Error("Rollback bundle was modified");
886889
const id = randomUUID(), stage = join(bundleState,"staging",id);
887890
ensurePrivateDir(dirname(stage));renameSync(backup,stage);
888-
save(journalPath,{version:1,id,release:previous,prior:{...meta,pin:previous.version}});
891+
save(journalPath,{version:2,id,origin:base,release:previous,prior:{...meta,pin:previous.version}});
889892
const currentBackup = join(bundleState,"backups",id);renameSync(paths.root,currentBackup);renameSync(stage,paths.root);recover();
890893
return {version:previous.version,activation:"rolled_back",reload_required:true,pin:previous.version};
891894
});

plugins/client/lifecycle.test.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,36 @@ test("cross-host discovery preserves origin and symlink checks on recorded owner
193193
assert.throws(()=>createLifecycle({...options,tool:"opencode"}).status(),/symbolic links/);assert.equal(requests.length,before);
194194
});
195195

196+
test("an incomplete first install cannot establish cross-host ownership or replace its origin",async t=>{
197+
const {lifecycle,options,state,control}=await fixture(t);await lifecycle.install();
198+
rmSync(join(state,"installation.json"));
199+
put(join(state,"transaction.json"),{version:1,id:randomUUID(),release:control.release.manifest,prior:null});
200+
let calls=0;
201+
const discovered=createLifecycle({...options,tool:"opencode",origin:"https://example.invalid",fetchImpl:async()=>{calls++;return new Response(null,{status:503})}});
202+
assert.equal((await discovered.update()).installation,"managed_or_unrecorded");
203+
assert.equal(calls,0);assert.equal(existsSync(join(state,"installation.json")),false);
204+
});
205+
206+
test("first-install recovery binds the recorded origin even for hosts with the same default directory",async t=>{
207+
const {lifecycle,options,state,control,paths}=await fixture(t);await lifecycle.install();
208+
const credential=readFileSync(paths.token,"utf8");rmSync(join(state,"installation.json"));
209+
put(join(state,"transaction.json"),{version:2,id:randomUUID(),origin:options.origin,release:control.release.manifest,prior:null});
210+
let calls=0;
211+
const other=createLifecycle({...options,tool:"cursor",origin:"https://example.invalid",fetchImpl:async()=>{calls++;return new Response(null,{status:503})}});
212+
await assert.rejects(other.update(),/recorded service origin/);assert.equal(calls,0);assert.equal(existsSync(join(state,"installation.json")),false);
213+
assert.equal((await lifecycle.update()).credential,"reused");
214+
assert.equal(get(join(state,"installation.json")).origin,options.origin);assert.equal(readFileSync(paths.token,"utf8"),credential);
215+
});
216+
217+
test("legacy first-install journals without an origin are preserved instead of assigning the caller's origin",async t=>{
218+
const {lifecycle,state,control,requests}=await fixture(t);await lifecycle.install();
219+
rmSync(join(state,"installation.json"));
220+
put(join(state,"transaction.json"),{version:1,id:randomUUID(),release:control.release.manifest,prior:null});
221+
const before=requests.length;
222+
await assert.rejects(lifecycle.update(),/recorded service origin/);assert.equal(requests.length,before);
223+
assert.equal(existsSync(join(state,"installation.json")),false);assert.equal(existsSync(join(state,"transaction.json")),true);
224+
});
225+
196226
test("intentional requests cache fixed version hints; retired contracts outrank pins",async t=>{
197227
const {lifecycle,paths,options}=await fixture(t);await lifecycle.install();await lifecycle.pin("0.4.0");
198228
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",

release.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
2-
"version": "0.4.1",
3-
"created_at": "2026-09-07T08:58:37.955Z",
4-
"updated_at": "2026-09-07T08:58:37.955Z",
2+
"version": "0.4.2",
3+
"created_at": "2026-09-07T09:12:23.208Z",
4+
"updated_at": "2026-09-07T09:12:23.208Z",
55
"client_contract": 1,
66
"minimum_client_contract": 0
77
}

0 commit comments

Comments
 (0)