From 1ae8c1ed7f65909e3de9a9f5d64d667c13938261 Mon Sep 17 00:00:00 2001 From: hopeatina Date: Wed, 23 Sep 2026 02:05:38 -0500 Subject: [PATCH] Stamp manifest_fingerprint from the packed files at publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan v3 §2/§5.4 leftover. The peer heartbeats manifest_fingerprint and signature, but both shipped empty. The publish workflow now stamps the fingerprint (sha256 over the packed file set, after build) and signs it when ORGX_MANIFEST_SIGNING_KEY is set, matching the server's verifyManifest. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/publish.yml | 5 +++ plugin.manifest.json | 2 +- scripts/stamp-manifest.mjs | 64 ++++++++++++++++++++++++++++ scripts/stamp-manifest.node-test.mjs | 34 +++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 scripts/stamp-manifest.mjs create mode 100644 scripts/stamp-manifest.node-test.mjs diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7b39f4b..cbd379e 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,6 +38,11 @@ jobs: - name: Build run: npm run build + - name: Stamp manifest fingerprint from the packed file set + run: node scripts/stamp-manifest.mjs + env: + ORGX_MANIFEST_SIGNING_KEY: ${{ secrets.ORGX_MANIFEST_SIGNING_KEY }} + - name: Pack release artifact run: npm run pack diff --git a/plugin.manifest.json b/plugin.manifest.json index 95c4f85..a12c8ad 100644 --- a/plugin.manifest.json +++ b/plugin.manifest.json @@ -11,5 +11,5 @@ "attention:reply_in_place" ], "driver_ids": ["opencode"], - "notes": "fingerprint + signature are populated by the npm prepublish step; the dev build ships unsigned and gets 'degraded' status from the server." + "notes": "fingerprint + signature are stamped by scripts/stamp-manifest.mjs in the publish workflow after build and before npm pack; dev builds ship empty and get 'degraded' status from the server." } diff --git a/scripts/stamp-manifest.mjs b/scripts/stamp-manifest.mjs new file mode 100644 index 0000000..cf374eb --- /dev/null +++ b/scripts/stamp-manifest.mjs @@ -0,0 +1,64 @@ +#!/usr/bin/env node +/** + * Fill `manifest_fingerprint` (and `signature`, when a key is provided) in + * plugin.manifest.json from the exact files `npm pack` will publish. + * + * The peer sends both fields on every heartbeat, but nothing populated them, + * so every shipped build reported an empty identity and the server could not + * tell one installed release from another (plan v3 §2, §5.4). + * + * fingerprint = sha256 over sorted "\0\n" for every + * packed file except plugin.manifest.json itself + * signature = base64(ed25519.sign(fingerprint)) with + * ORGX_MANIFEST_SIGNING_KEY (PEM), matching the server's + * lib/licenses/manifest.ts verifyManifest + * + * Run immediately before `npm pack` in the release workflow. + */ +import { execFileSync } from 'node:child_process'; +import { createHash, createPrivateKey, sign } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const MANIFEST = 'plugin.manifest.json'; + +export function computeManifestFingerprint(files, readFile) { + const lines = files + .filter((path) => path !== MANIFEST) + .sort() + .map((path) => `${path}\0${createHash('sha256').update(readFile(path)).digest('hex')}\n`); + return `sha256:${createHash('sha256').update(lines.join('')).digest('hex')}`; +} + +export function signManifestFingerprint(fingerprint, privateKeyPem) { + return sign(null, Buffer.from(fingerprint, 'utf8'), createPrivateKey(privateKeyPem)).toString( + 'base64' + ); +} + +function packedFiles(root) { + const out = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], { + cwd: root, + encoding: 'utf8', + }); + return JSON.parse(out)[0].files.map((file) => file.path); +} + +function main() { + const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + const manifestPath = resolve(root, MANIFEST); + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')); + const fingerprint = computeManifestFingerprint(packedFiles(root), (path) => + readFileSync(resolve(root, path)) + ); + const key = process.env.ORGX_MANIFEST_SIGNING_KEY; + manifest.manifest_fingerprint = fingerprint; + manifest.signature = key ? signManifestFingerprint(fingerprint, key) : ''; + writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + console.log( + `stamp-manifest: ${fingerprint} (${key ? 'signed' : 'unsigned: ORGX_MANIFEST_SIGNING_KEY not set'})` + ); +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(); diff --git a/scripts/stamp-manifest.node-test.mjs b/scripts/stamp-manifest.node-test.mjs new file mode 100644 index 0000000..c4cebf8 --- /dev/null +++ b/scripts/stamp-manifest.node-test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict'; +import { generateKeyPairSync, verify } from 'node:crypto'; +import { test } from 'node:test'; + +import { computeManifestFingerprint, signManifestFingerprint } from './stamp-manifest.mjs'; + +const contents = { 'a.mjs': 'a', 'b/c.mjs': 'c', 'plugin.manifest.json': '{"x":1}' }; +const read = (path) => Buffer.from(contents[path]); + +test('fingerprint is order-independent and excludes the manifest itself', () => { + const one = computeManifestFingerprint(['a.mjs', 'b/c.mjs', 'plugin.manifest.json'], read); + const two = computeManifestFingerprint(['b/c.mjs', 'a.mjs'], read); + assert.equal(one, two); + assert.match(one, /^sha256:[0-9a-f]{64}$/); +}); + +test('fingerprint changes when any packed byte changes', () => { + const before = computeManifestFingerprint(['a.mjs'], read); + const after = computeManifestFingerprint(['a.mjs'], () => Buffer.from('A')); + assert.notEqual(before, after); +}); + +test('signature verifies the way the server verifies it', () => { + const { privateKey, publicKey } = generateKeyPairSync('ed25519'); + const fingerprint = computeManifestFingerprint(['a.mjs'], read); + const signature = signManifestFingerprint( + fingerprint, + privateKey.export({ type: 'pkcs8', format: 'pem' }) + ); + assert.equal( + verify(null, Buffer.from(fingerprint, 'utf8'), publicKey, Buffer.from(signature, 'base64')), + true + ); +});