Skip to content

[3/4] buzz personas: publish agent definitions from the CLI - #5844

Open
Maxwellimus wants to merge 1 commit into
maxwell/sdk-definition-text-rulesfrom
maxwell/cli-personas
Open

[3/4] buzz personas: publish agent definitions from the CLI#5844
Maxwellimus wants to merge 1 commit into
maxwell/sdk-definition-text-rulesfrom
maxwell/cli-personas

Conversation

@Maxwellimus

@Maxwellimus Maxwellimus commented Aug 14, 2026

Copy link
Copy Markdown

Personas could only be created by clicking through Buzz Desktop, so nothing
scriptable could stand up an agent roster — including the agents themselves.
buzz personas create|list|get|delete writes the same kind:30175 coordinates
Desktop reads, from a flag set or straight from a .agent.json export.

These are owner-authored events, so the signing key is the owner: no NIP-OA
auth tag is involved, and running with a different key publishes to a coordinate
space the owner's Desktop never reads. Publishing a definition does not start an
agent — launching one mints key material and stays a Desktop operation.

Two relay behaviors shape the implementation

  • A write must be stamped past the coordinate's current head. NIP-33 breaks a
    created_at tie by lowest event id, so a same-second rewrite can otherwise
    lose to the event it was replacing.
  • soft_delete_by_coordinate matches created_at <= tombstone, and its result
    only feeds a debug log. A tombstone older than its target is accepted, deletes
    nothing, and reports OK — so delete stamps from the head it just read, then
    re-reads the coordinate to confirm. The re-read only raises a conflict for a
    head strictly newer than the tombstone, since a lagging replica can still
    return the deleted head.

--replace carries the published shared tag forward. A snapshot has no
shared field and the flag defaults off, so re-publishing a shared persona to fix
a typo would otherwise drop it out of every other member's catalog silently;
personas get now prints the state so it is checkable.

Snapshots and avatars

--from decodes a typed v1 snapshot rather than probing a loose JSON value, so a
wrong-typed field is an error instead of a silently dropped entry, and the file is
capped at the 5 MiB Desktop itself refuses to import. It refuses rather than
repairs a snapshot that would publish a persona Desktop can't mint from:
respondTo=allowlist with no pubkeys, an allowlist entry that isn't 64 hex
chars, parallelism outside 1-32, an unknown respondTo, or definition text
carrying invisible characters. --parallelism is bounded at the flag by the same
1-32 range.

A snapshot carries no id, so the slug derived from its name would mint a second
coordinate for a persona Desktop already publishes under its record UUID, leaving
two records of the same name. An import adopts the published coordinate — but
only when the definition there is the one being imported. A shared display name
alone can be a different persona, and adopting its coordinate would overwrite it;
that case publishes at its own slug and says so on stderr. The comparison covers
every field the snapshot's definition block carries and deliberately excludes
the avatar, which lives in profile and is re-encoded on the way in.

Avatars follow Desktop's reader rather than a CLI-only rule. --avatar takes a
local image, downscales it to 512px, re-encodes it, and then measures: it rides
inline in the event when it fits the bounds Desktop renders — 8 KiB for SVG,
256 KiB for raster, both inside the relay's 256 KiB content cap — and uploads to
media storage when it doesn't. Flat art typically lands back inside the bound and
never reaches media storage at all. --avatar-url is checked against the same
rules Desktop's reader applies, so a URL it would silently drop is refused
instead of published to render as nothing.

Re-encoding, not the size check, is the load-bearing part. Metadata — EXIF,
colour profiles, comments — is an identity channel: media storage refuses it
outright, so a photo straight off a camera failed to upload, and an inline avatar
bypasses that validator entirely and would carry the photo's GPS coordinates into
a world-readable event. Every raster is re-encoded for that reason, whichever
path it takes. Decoding and re-encoding drops metadata by construction rather
than by stripping known chunks, so it cannot drift from the relay's allowlist the
way a structural stripper would. EXIF orientation is baked into the pixels first,
because dropping the tag without applying it publishes the avatar sideways. The
decoder is bounded to 8192px a side, so a small file declaring huge dimensions is
refused rather than expanded. GIF and animated images pass through whole, since
re-encoding would flatten them to one frame, and WebP re-encodes to PNG because
image's WebP encoder is lossless-only and would inflate a lossy source.

--from carries a snapshot's inlined avatar through that same path, so a
Desktop export round-trips with its image. The upload runs after the --replace
conflict check: an upload that a rejected write would strand leaves an orphan
blob behind.

Test plan

  • just ci

  • Live against a local relay:

    • inline pass-through — 70-byte PNG → 118-char data URL in the event
    • downscale back to inline — 4000px flat PNG whose data URL was 584,556 chars
      (2.2× the bound) publishes inline at 7,458 chars, no upload
    • photographic content — 1400px PNG normalizes to 512px and uploads, since PNG
      is lossless and photo detail stays past the bound
    • metadata-bearing image — 469 KiB PNG carrying a tEXt chunk previously
      failed with 422 media contains metadata; now succeeds
    • non-image → exit 1 with nothing published; --avatar + --avatar-url
      exit 1

    The inline metadata-strip and the URL/allowlist/parallelism gates landed after
    that live run and are covered by unit tests only; the runbook rows for them are
    in §6.13.

  • Runbook rows added to crates/buzz-cli/TESTING.md §6.13.

Deferred

  • Animated avatars are detected but not normalized. GIF, APNG, and animated
    WebP pass through untouched rather than being flattened to a first frame, so
    they are the one avatar kind that can still carry metadata: past the inline
    bound they hit the media validator and fail with an actionable message, and
    inside it they publish with their metadata intact. Desktop strips these
    structurally in media_gif.rs and media_animated.rs (~930 lines); moving
    those to a shared crate is the follow-up, and it also closes the identical
    pre-existing gap in buzz upload file, which has never sanitized its uploads
    either.
  • normalize_avatar duplicates a ~20-line decode/orient/encode sequence that
    Desktop's sanitize_image_for_upload also performs. Depending on buzz-media
    instead would pull rust-s3, axum, and mp4 into this binary, and it has no
    features to gate that. The duplication is a mechanism, not a policy —
    re-encoding yields metadata-free output by construction, so there is no
    allowlist to keep in sync with the relay's validator.
  • No pagination past list_owned's 500-event cap. It warns on stderr when it
    hits the cap rather than truncating silently.
  • The global --format flag isn't threaded through these subcommands, matching
    19 of the 24 existing dispatch arms.

@Maxwellimus
Maxwellimus marked this pull request as ready for review August 14, 2026 06:22
@Maxwellimus
Maxwellimus requested a review from a team as a code owner August 14, 2026 06:22

@wolfyy970 wolfyy970 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The owner-signing boundary and the monotonic create/delete handling look right, and the focused suites pass 35/35. I found two paths that still let the CLI report success for a definition Desktop cannot instantiate.

  1. The behavioral validation is incomplete. --parallelism accepts 0 or values above 32, while --from casts any JSON u64 to u32—so 4294967297 silently becomes 1. Imported allowlists are checked only for being non-empty; the current test even blesses "aa" and "bb", although Desktop requires normalized, deduplicated 64-character hex pubkeys. Please run both flags and imported values through the same mint-time validation before publishing, with regressions for 0, 33, overflow, malformed keys, and uppercase duplicates.

  2. --from is not yet a fail-closed Desktop snapshot decode. It reads an unbounded file into a loose Value, then filter_map silently drops wrong-typed respondToAllowlist and namePool entries and accepts incomplete v1 envelopes. That repairs malformed input under the user, contrary to the boundary described in the PR. Please cap JSON at the Desktop 5 MiB limit and decode and validate a typed v1 snapshot shape before projecting the deliberately portable definition fields.

These are narrow fixes; I would keep the current 30175 publication architecture.

@Maxwellimus
Maxwellimus force-pushed the maxwell/cli-personas branch from 976da4f to bf0a60c Compare August 14, 2026 18:39
@Maxwellimus
Maxwellimus force-pushed the maxwell/cli-personas branch 2 times, most recently from cf20d8a to 3ba9ecb Compare August 14, 2026 19:59
@Maxwellimus
Maxwellimus force-pushed the maxwell/cli-personas branch from 3ba9ecb to cbd821f Compare August 15, 2026 00:29
Personas could only be created by clicking through Buzz Desktop, so
nothing scriptable could stand up an agent roster — including the agents
themselves. `buzz personas create|list|get|delete` writes the same
kind:30175 coordinates Desktop reads, from a flag set or straight from a
`.agent.json` export.

These are owner-authored events, so the signing key IS the owner: no
NIP-OA auth tag is involved, and running with a different key publishes
to a coordinate space the owner's Desktop never reads. Publishing a
definition does not start an agent; launching one mints key material and
stays a Desktop operation.

Two relay behaviors shape the implementation:

- A write must be stamped past the coordinate's current head. NIP-33
  breaks a `created_at` tie by lowest event id, so a same-second rewrite
  can otherwise lose to the event it was replacing.
- `soft_delete_by_coordinate` matches `created_at <= tombstone`, and its
  result only feeds a debug log. A tombstone older than its target is
  accepted, deletes nothing, and reports OK — so delete stamps from the
  head it just read and then re-reads the coordinate to confirm. The
  re-read only raises a conflict for a head strictly newer than the
  tombstone, since a lagging replica can still return the deleted head.

`--from` refuses rather than repairs a snapshot that would publish a
persona Desktop can't mint from: `respondTo=allowlist` with no pubkeys,
an unknown `respondTo`, or definition text carrying invisible characters.

Avatars follow Desktop's reader rather than a CLI-only rule. `--avatar`
takes a local image and carries it inline as a data URL when it fits the
bounds Desktop renders — 8 KiB for SVG, 256 KiB for raster, both inside
the relay's 256 KiB content cap. Anything larger is downscaled to 512px
and re-encoded, then re-checked against the bound: flat art typically
lands back inside it and never reaches media storage at all, and only
what still doesn't fit is uploaded.

Re-encoding is also what makes that upload work. Media storage refuses
images carrying metadata — EXIF, colour profiles, comments — as an
identity channel, so a photo straight off a camera failed outright.
Decoding and re-encoding drops metadata by construction rather than by
stripping known chunks, so it cannot drift from the relay's allowlist the
way a structural stripper would. EXIF orientation is baked into the
pixels first, because dropping the tag without applying it publishes the
avatar sideways. GIF passes through untouched, since re-encoding would
flatten animation, and WebP re-encodes to PNG because `image`'s WebP
encoder is lossless-only and would inflate a lossy source.

`--from` carries a snapshot's inlined avatar through that same path, so a
Desktop export round-trips with its image. The upload runs after the
`--replace` conflict check: an upload that a rejected write would strand
leaves an orphan blob behind.

Signed-off-by: Max Lampert <maxwell@squareup.com>
@Maxwellimus
Maxwellimus force-pushed the maxwell/cli-personas branch from cbd821f to 594e2ec Compare August 15, 2026 00:40
@Maxwellimus

Copy link
Copy Markdown
Author

🤖 Both items addressed in 594e2ece9.

Behavioral validation. --parallelism is now bounded at the flag
(value_parser range 1-32), and the --from path runs the imported value
through the same range. The allowlist goes through normalize_allowlist, which
requires 64 ASCII hex chars, lowercases, and dedupes preserving insertion order —
mirroring validate_respond_to_allowlist. Regressions added for 0, 33, 99, u32
overflow, a malformed key, and an uppercase duplicate that must collapse to one
lowercase entry, on both the flag and file paths.

The u32 truncation is gone rather than range-checked: 4294967297 used to become
1 through an as cast, and now fails to decode.

Fail-closed snapshot decode. --from deserializes a typed v1
SnapshotDefinition instead of projecting off a serde_json::Value, so a
wrong-typed respondToAllowlist or namePool entry is an error rather than a
silently dropped one. Unknown fields are still ignored so a newer Desktop export
imports. JSON is capped at 5 MiB, matching MAX_SNAPSHOT_JSON_BYTES — read one
byte past the cap rather than trusting metadata.

I extended the same treatment to teams in [4/4], which has the identical defect
you didn't flag: a non-string instructions was dropped by as_str(),
publishing a team missing the text that drives it and reporting success. Team
snapshots are capped at the 25 MiB MAX_TEAM_SNAPSHOT_JSON_BYTES.

One thing beyond your review, since it is the same class of silent-repair
bug: a snapshot carries no id, so importing one already published from Desktop
minted a second coordinate at the name-derived slug, next to the record UUID
Desktop writes. An import now adopts the published coordinate, but only when the
definition there is the one being imported — a shared display name alone can be a
different persona, and adopting its coordinate would overwrite it. That case
publishes at its own slug and says so on stderr.

Agreed on keeping the 30175 publication architecture.

@Maxwellimus
Maxwellimus requested a review from wolfyy970 August 15, 2026 00:49

@wolfyy970 wolfyy970 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Max. I reviewed 594e2ece9. The two items from 976da4f74e21 are fixed: parallelism is bounded on both paths, overflow no longer truncates, allowlists match Desktop mint rules, and --from fails closed on a wrong-typed v1 definition instead of repairing it.

One new hole in the coordinate-adoption work:

--slug is still overwritten when --from finds an identical published persona. resolve_create says flags win, and they do until cmd_create replaces the slug. buzz personas create --from herring.agent.json --slug herring-copy --replace then overwrites the UUID coordinate Desktop already published, and never writes herring-copy. Skip adoption when --slug is set. A caller who named a coordinate should get that coordinate, or a conflict at that coordinate.

Keep the adoption for the no-slug --from case. That is the right fix for the duplicate Desktop UUID.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants