Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions Utilities/Use-Latest-BUZZ-CLI.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
[CmdletBinding()]
param(
[string]$BuzzCheckout = "",
[switch]$NoLaunch
)

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

$RequiredSignatureFix = "583af02299e20cbd8603044c7844bc128e4e06cd"
$BuzzRemote = "https://github.com/block/buzz.git"
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..")).Path

function Require-Command([string]$Name) {
$command = Get-Command $Name -ErrorAction SilentlyContinue
if (-not $command) {
throw "Required command '$Name' was not found on PATH. Install it first, then rerun this helper."
}
return $command.Source
}

function Run-Git([string[]]$Arguments) {
& git @Arguments
if ($LASTEXITCODE -ne 0) {
throw "git $($Arguments -join ' ') failed with exit code $LASTEXITCODE."
}
}

Require-Command "git" | Out-Null
Require-Command "cargo" | Out-Null

if (-not $BuzzCheckout.Trim()) {
$localBase = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { $env:TEMP }
$BuzzCheckout = Join-Path $localBase "PlotPickle\Developer\buzz-upstream"
}

Write-Host ""
Write-Host "PlotPickle #1422 - latest BUZZ CLI verification" -ForegroundColor Cyan
Write-Host "BUZZ source: $BuzzRemote"
Write-Host "Managed checkout: $BuzzCheckout"
Write-Host "Required upstream fix: $RequiredSignatureFix"
Write-Host ""

if (-not (Test-Path $BuzzCheckout)) {
$parent = Split-Path -Parent $BuzzCheckout
New-Item -ItemType Directory -Force -Path $parent | Out-Null
Write-Host "Cloning current BUZZ main..." -ForegroundColor Cyan
Run-Git @("clone", "--filter=blob:none", "--branch", "main", $BuzzRemote, $BuzzCheckout)
}

if (-not (Test-Path (Join-Path $BuzzCheckout ".git"))) {
throw "The managed BUZZ checkout exists but is not a Git repository: $BuzzCheckout"
}

Push-Location $BuzzCheckout
try {
$origin = (& git remote get-url origin).Trim()
if ($LASTEXITCODE -ne 0) { throw "Could not read the BUZZ checkout origin." }
$normalizedOrigin = $origin.ToLowerInvariant().TrimEnd('/').Replace(".git", "")
if ($normalizedOrigin -ne "https://github.com/block/buzz") {
throw "Refusing to update an unexpected repository at $BuzzCheckout. Origin is '$origin'."
}

$dirty = (& git status --porcelain)
if ($LASTEXITCODE -ne 0) { throw "Could not inspect the BUZZ checkout." }
if ($dirty) {
throw "The managed BUZZ checkout has local changes. Nothing was overwritten. Clean or remove '$BuzzCheckout' and rerun."
}

Write-Host "Updating to current BUZZ main..." -ForegroundColor Cyan
Run-Git @("fetch", "--prune", "origin", "main")
Run-Git @("checkout", "--detach", "origin/main")

$buzzHead = (& git rev-parse HEAD).Trim()
if ($LASTEXITCODE -ne 0) { throw "Could not resolve the current BUZZ source commit." }

& git merge-base --is-ancestor $RequiredSignatureFix HEAD
if ($LASTEXITCODE -ne 0) {
throw "Current BUZZ main does not contain the required #6884 signature-preservation fix. Refusing the Story Bridge test."
}

Write-Host "BUZZ main: $buzzHead" -ForegroundColor Green
Write-Host "Confirmed: #6884 signature fix is present." -ForegroundColor Green
Write-Host "Building only buzz-cli in release mode..." -ForegroundColor Cyan
& cargo build --locked -p buzz-cli --release
if ($LASTEXITCODE -ne 0) {
throw "BUZZ CLI release build failed with exit code $LASTEXITCODE."
}

$buzzExe = Join-Path $BuzzCheckout "target\release\buzz.exe"
if (-not (Test-Path $buzzExe)) {
throw "BUZZ CLI build completed but buzz.exe was not found at $buzzExe"
}

& $buzzExe --help *> $null
if ($LASTEXITCODE -ne 0) {
throw "The newly built BUZZ CLI could not start."
}

$env:BUZZ_CLI_PATH = $buzzExe
Write-Host ""
Write-Host "READY" -ForegroundColor Green
Write-Host "PlotPickle will use: $buzzExe"
Write-Host "BUZZ source commit: $buzzHead"
Write-Host "Installed BUZZ Desktop and BUZZ profile data were not modified."
Write-Host ""

if ($NoLaunch) {
Write-Host "This PowerShell session now has BUZZ_CLI_PATH set to the fixed CLI." -ForegroundColor Yellow
Write-Host "Launch PlotPickle from this same session to inherit it:"
Write-Host " & '$RepoRoot\Start-PlotPickle.bat'"
return
}

$launcher = Join-Path $RepoRoot "Start-PlotPickle.bat"
if (-not (Test-Path $launcher)) {
throw "PlotPickle launcher was not found at $launcher"
}

Write-Host "Launching PlotPickle with the fixed latest BUZZ CLI..." -ForegroundColor Cyan
Write-Host "In Settings > BUZZ, refresh diagnostics and run the Afterglow -> Tamsin Story Bridge proof."
Start-Process -FilePath $launcher -WorkingDirectory $RepoRoot
}
finally {
Pop-Location
}
4 changes: 2 additions & 2 deletions build/story-workflow-buzz-bridge-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
dedupeStoryBridgeContributions,
encodeStoryBridgeDispatchEnvelope,
normalizeStoryBridgeContribution,
storyBridgeResultMatchesRequest,
STORY_BRIDGE_DISPATCH_MARKER,
STORY_BRIDGE_RESULT_MARKER,
type StoryBridgeRequest,
Expand Down Expand Up @@ -406,9 +407,8 @@ async function collect(request: IncomingMessage, bridge: StoryBridgeRequest, cur
const room = await storyRoom(request, bridge, false);
if (!room?.id) return { ...fallback(bridge, "The private project Story Room is not available."), ...observability(bridge, startedAt) };
const messages = await recentMessages(request, room.id);
const requestToken = `\"requestId\":\"${bridge.requestId}\"`;
const contributions = messages.flatMap((message) => {
if (!message.content.startsWith(`${STORY_BRIDGE_RESULT_MARKER}\n`) || !message.content.includes(requestToken)) return [];
if (!storyBridgeResultMatchesRequest(message.content, bridge.requestId)) return [];
return [normalizeStoryBridgeContribution({
request: bridge,
envelope: message.content,
Expand Down
7 changes: 6 additions & 1 deletion core/buzz/nostr-event-verification.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,12 @@ export function canonicalNostrEventId(value) {

export function verifyNostrEventSignature(value) {
const event = normalizeNostrEvent(value);
if (!event) return { valid: false, eventId: "", pubkey: "", reason: "BUZZ did not return a complete signed Nostr event." };
if (!event) return {
valid: false,
eventId: "",
pubkey: "",
reason: "BUZZ did not return a complete signed Nostr event, including its public signature.",
};
const canonicalId = canonicalNostrEventId(event);
if (!canonicalId || canonicalId !== event.id) {
return { valid: false, eventId: event.id, pubkey: event.pubkey, reason: "The BUZZ event id does not match its signed event content." };
Expand Down
1 change: 1 addition & 0 deletions core/story-workflow/buzz-story-bridge-core.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export function createStoryBridgeRequest(input: {

export function encodeStoryBridgeDispatchEnvelope(request: StoryBridgeRequest): string;
export function decodeStoryBridgeResultEnvelope(content: string): Record<string, unknown> | null;
export function storyBridgeResultMatchesRequest(content: string, requestId: string): boolean;
export function normalizeStoryBridgeContribution(input: {
readonly request: StoryBridgeRequest;
readonly envelope: string | Record<string, unknown>;
Expand Down
7 changes: 7 additions & 0 deletions core/story-workflow/buzz-story-bridge-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,13 @@ export function decodeStoryBridgeResultEnvelope(content) {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}

export function storyBridgeResultMatchesRequest(content, requestId) {
const expectedRequestId = cleanText(requestId, 180);
if (!expectedRequestId) return false;
const envelope = decodeStoryBridgeResultEnvelope(content);
return cleanText(envelope?.requestId, 180) === expectedRequestId;
}

function rejection(request, envelope, verification, state, reason) {
return {
contributionId: cleanText(envelope?.contributionId, 180) || verification.eventId || "",
Expand Down
3 changes: 2 additions & 1 deletion tests/issue-1422-buzz-story-bridge-hardening.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ test("#1422 Story Bridge puts the exact approved BUZZ Agent in the private room
"ensurePrivateBuzzAgentMembership",
"agentPubkey: bridge.expectedAgentPubkey",
"STORY_BRIDGE_RESULT_MARKER",
"storyBridgeResultMatchesRequest",
"Copy every correlation ID and target/evidence ref exactly",
"canonical Agent mention",
]) assert.ok(gateway.includes(contract), `Story Bridge dispatch is missing managed-Agent live targeting: ${contract}`);
Expand Down Expand Up @@ -172,7 +173,7 @@ test("#1422 Story Bridge puts the exact approved BUZZ Agent in the private room
assert.match(membership, /replace\(\/nsec1\[a-z0-9\]\+\/gi/);
assert.ok(
gateway.includes("message.content.startsWith(`${STORY_BRIDGE_DISPATCH_MARKER}\\n`)")
&& gateway.includes("message.content.includes(`\\\"requestId\\\":\\\"${requestId}\\\"`)"),
&& gateway.includes("storyBridgeResultMatchesRequest(message.content, bridge.requestId)"),
"The structured dispatch marker/request identity must remain stable for retry/idempotency detection.",
);
});
Expand Down
33 changes: 33 additions & 0 deletions tests/issue-1422-buzz-story-bridge.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
createStoryBridgeRequest,
dedupeStoryBridgeContributions,
normalizeStoryBridgeContribution,
storyBridgeResultMatchesRequest,
STORY_BRIDGE_RESULT_MARKER,
} from "../core/story-workflow/buzz-story-bridge-core.mjs";
import {
Expand Down Expand Up @@ -155,6 +156,38 @@ test("#1422 verifies signed Nostr event identity locally and rejects tampered co
assert.equal(verifyNostrEventSignature(tampered).valid, false);
});

test("#1422 matches pretty-printed BUZZ result envelopes by parsed request identity", () => {
const bridge = request();
const compact = resultEnvelope(bridge);
const payload = JSON.parse(compact.slice(compact.indexOf("\n") + 1));
const pretty = `${STORY_BRIDGE_RESULT_MARKER}\n${JSON.stringify(payload, null, 2)}`;

assert.equal(storyBridgeResultMatchesRequest(compact, bridge.requestId), true);
assert.equal(storyBridgeResultMatchesRequest(pretty, bridge.requestId), true);
assert.equal(storyBridgeResultMatchesRequest(pretty, "story-bridge:different"), false);
assert.throws(
() => storyBridgeResultMatchesRequest(`${STORY_BRIDGE_RESULT_MARKER}\n{broken`, bridge.requestId),
/JSON/,
);
assert.equal(storyBridgeResultMatchesRequest(`preface\n${pretty}`, bridge.requestId), false);
});

test("#1422 fails closed with a precise compatibility reason when BUZZ omits the public event signature", () => {
const bridge = request();
const envelope = resultEnvelope(bridge);
const { sig: _omittedPublicSignature, ...signatureStrippedEvent } = signedNostrEvent(envelope, 3n);
const contribution = normalizeStoryBridgeContribution({
request: bridge,
envelope,
rawEvent: signatureStrippedEvent,
currentRevision: "9",
});

assert.equal(contribution.accepted, false);
assert.equal(contribution.state, "unverified");
assert.match(contribution.reason, /public signature/i);
});

test("#1422 creates stable private project bridge requests and degrades locally until official Agent signers exist", () => {
const first = request();
const second = request();
Expand Down
Loading