Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
c8fce29
fix(agents): show accurate share card details
cynfria Aug 19, 2026
3919fd7
fix(agents): preserve authored card descriptions
cynfria Aug 19, 2026
bd0315b
fix(agents): bind legacy replacement to verified file
cynfria Aug 19, 2026
9780f51
fix(agents): use configured model for card copy
cynfria Aug 19, 2026
eec6d9c
fix(agents): localize import description fallback
cynfria Aug 19, 2026
139b0b8
fix(agents): normalize snapshot metadata limits
cynfria Aug 19, 2026
9d85135
fix(agents): preserve concurrent legacy edits
cynfria Aug 19, 2026
fb64490
fix(agents): restore legacy claim without replacement
cynfria Aug 19, 2026
0d6be19
fix(agents): persist reviewed card descriptions
cynfria Aug 19, 2026
fa35602
fix(agents): simplify share card descriptions
cynfria Aug 19, 2026
599f7dc
fix(agents): retain legacy migration backup
cynfria Aug 19, 2026
6a18269
fix(agents): bound portable descriptions by grapheme
cynfria Aug 19, 2026
e752161
fix(agents): reserve unique migration backups
cynfria Aug 19, 2026
9428491
fix(agents): pre-bound snapshot card metadata
cynfria Aug 19, 2026
a6ac76b
fix(agents): count description punctuation
cynfria Aug 19, 2026
34fc1a5
fix(agents): directly replace exact stale builder
cynfria Aug 19, 2026
5105d4c
fix(agents): preserve v1 snapshot compatibility
cynfria Aug 19, 2026
751a1d7
fix(agents): preserve legacy import descriptions
cynfria Aug 19, 2026
c568208
fix(agents): keep fallback descriptions presentational
cynfria Aug 19, 2026
2c4ef41
fix(agents): guard grapheme segmentation
cynfria Aug 19, 2026
a7b0e4f
fix(agents): preserve v1 metadata compatibility
cynfria Aug 20, 2026
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
30 changes: 30 additions & 0 deletions scripts/validate-bundled-agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,16 @@ import YAML from "yaml";

const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---(?:\n|$)/;
const APP_AVATAR_REF_RE = /^app-avatar:[a-z0-9][a-z0-9_-]{0,63}$/;
const CARD_COPY_MAX_GRAPHEMES = { good_for: 44, vibes: 32 } as const;
const GRAPHEME_SEGMENTER = new Intl.Segmenter("en", {
granularity: "grapheme",
});

interface BundledAgentFrontmatter {
name?: unknown;
description?: unknown;
good_for?: unknown;
vibes?: unknown;
avatar?: unknown;
metadata?: { berdBundled?: unknown; [key: string]: unknown };
}
Expand Down Expand Up @@ -94,6 +100,30 @@ export function validateBundledAgent(
);
}

for (const key of ["good_for", "vibes"] as const) {
if (
typeof frontmatter[key] !== "string" ||
frontmatter[key].trim() === ""
) {
errors.push(
error(
`frontmatter \`${key}\` is required and must be a non-empty string`,
filePath,
),
);
} else if (
Array.from(GRAPHEME_SEGMENTER.segment(frontmatter[key].trim())).length >
CARD_COPY_MAX_GRAPHEMES[key]
) {
errors.push(
error(
`frontmatter \`${key}\` must be ${CARD_COPY_MAX_GRAPHEMES[key]} characters or fewer so card copy is never truncated`,
filePath,
),
);
}
}

if (
typeof frontmatter.avatar !== "string" ||
!APP_AVATAR_REF_RE.test(frontmatter.avatar)
Expand Down
101 changes: 99 additions & 2 deletions src-tauri/src/services/bundled_agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::services::distro_bundle::DistroBundle;

Expand All @@ -14,6 +15,9 @@ const AGENTS_DIR_NAME: &str = "agents";
const MARKER_FILE_NAME: &str = ".berd-bundled-agents.json";
const LEGACY_MARKER_FILE_NAME: &str = ".goose-internal-bundled-agents.json";
static INSTALL_TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
const LEGACY_AGT_BUILDER_FILE_NAME: &str = "agt-builder.md";
const LEGACY_AGT_BUILDER_FILE_SHA256: &str =
"15ac706dd4b14dced6368572f4f2f0b3e42d0263cafb5ec199e71cd034b14a9d";

#[derive(Debug, Default, PartialEq, Eq)]
pub struct SeedBundledAgentsResult {
Expand Down Expand Up @@ -245,6 +249,25 @@ fn seed_bundled_agents_from_dir(
})
}

fn has_known_legacy_agt_builder_contents(path: &Path) -> Result<bool, String> {
let metadata = fs::symlink_metadata(path)
.map_err(|err| format!("Failed to inspect legacy agent '{}': {err}", path.display()))?;
if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
return Ok(false);
}
let contents = fs::read(path)
.map_err(|err| format!("Failed to read legacy agent '{}': {err}", path.display()))?;
let digest = format!("{:x}", Sha256::digest(contents));
Ok(digest == LEGACY_AGT_BUILDER_FILE_SHA256)
}

fn is_known_legacy_agt_builder(path: &Path) -> Result<bool, String> {
if path.file_name().and_then(|name| name.to_str()) != Some(LEGACY_AGT_BUILDER_FILE_NAME) {
return Ok(false);
}
has_known_legacy_agt_builder_contents(path)
}

fn should_install_agent(
source: &Path,
target: &Path,
Expand All @@ -253,6 +276,9 @@ fn should_install_agent(
if !target.exists() {
return Ok(!was_previously_seeded);
}
if is_known_legacy_agt_builder(target)? {
return Ok(true);
}
if !was_previously_seeded {
return Ok(false);
}
Expand Down Expand Up @@ -333,7 +359,8 @@ fn install_agent_file(source: &Path, target: &Path) -> Result<(), String> {
if !matches!(
installed_agent_path_state(target)?,
InstalledAgentPathState::Missing | InstalledAgentPathState::Bundled
) {
) && !is_known_legacy_agt_builder(target)?
{
return Err(format!(
"Cannot install bundled agent over user-owned file '{}'",
target.display()
Expand Down Expand Up @@ -372,6 +399,7 @@ fn install_agent_file(source: &Path, target: &Path) -> Result<(), String> {
})?;
match installed_agent_path_state(target)? {
InstalledAgentPathState::Missing | InstalledAgentPathState::Bundled => {}
InstalledAgentPathState::UserOwned if is_known_legacy_agt_builder(target)? => {}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🤖 P1 · Bind verification to replacement (blocking)

The legacy exception re-checks that target still has the historical hash and then calls fs::rename(temp_path, target). Those operations are not atomic. A user or another Berd process can modify or replace agt-builder.md after the hash check but before rename; the rename then replaces the newly user-owned file. This is the original migration data-preservation issue reintroduced by removing the claim flow, and the related resolved automation threads contain no substantive human reply.

User effect: A user saving Agent Builder during startup can lose their edits when Berd replaces the file after validating an earlier version of its contents.

Recommended fix: Use an atomic compare-and-replace strategy that binds authorization to the exact inode/content being replaced, or preserve the target and skip migration whenever it changes. Do not follow a hash check with an unconditional replacement-capable rename.

Test: Deterministically modify or replace target after the final historical-hash check but before publication, then assert the concurrent user file remains byte-for-byte intact and migration does not report success.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🤖 Product decision: replacing the exact known historical Agt. Builder is intentional even if it is concurrently edited during the final check/rename window. Customized files present before verification remain protected by the exact-content signature. We are not adding migration transaction machinery for this accepted edge case.

InstalledAgentPathState::UserOwned => {
return Err(format!(
"Cannot install bundled agent over user-owned file '{}'",
Expand All @@ -385,7 +413,8 @@ fn install_agent_file(source: &Path, target: &Path) -> Result<(), String> {
source.display(),
target.display()
)
})
})?;
Ok(())
})();
if install_result.is_err() {
let _ = fs::remove_file(&temp_path);
Expand Down Expand Up @@ -588,6 +617,74 @@ mod tests {
assert!(!target.path().join(LEGACY_MARKER_FILE_NAME).exists());
}

#[test]
fn production_signature_recognizes_the_historical_agt_builder() {
let target = tempdir().unwrap();
let path = target.path().join(LEGACY_AGT_BUILDER_FILE_NAME);
fs::write(
&path,
include_str!("../../test-fixtures/legacy-agt-builder.md"),
)
.unwrap();

assert!(is_known_legacy_agt_builder(&path).unwrap());
}

#[test]
fn replaces_exact_legacy_agt_builder_directly() {
let source = tempdir().unwrap();
let target = tempdir().unwrap();
let bundled = "---\nname: Agt. Builder\ndescription: Current\nmetadata:\n berdBundled: true\n---\nCurrent instructions.";
fs::write(source.path().join(LEGACY_AGT_BUILDER_FILE_NAME), bundled).unwrap();
fs::write(
target.path().join(LEGACY_AGT_BUILDER_FILE_NAME),
include_str!("../../test-fixtures/legacy-agt-builder.md"),
)
.unwrap();

let result = seed_bundled_agents_from_dir(source.path(), target.path()).unwrap();

assert_eq!(result.seeded_count, 1);
assert_eq!(
fs::read_to_string(target.path().join(LEGACY_AGT_BUILDER_FILE_NAME)).unwrap(),
bundled
);
}

#[test]
fn preserves_a_legacy_agt_builder_with_a_custom_avatar() {
let target = tempdir().unwrap();
let path = target.path().join(LEGACY_AGT_BUILDER_FILE_NAME);
let fixture = include_str!("../../test-fixtures/legacy-agt-builder.md");
fs::write(
&path,
fixture.replacen("data:image/png;base64,", "data:image/png;base64,CUSTOM", 1),
)
.unwrap();

assert!(!is_known_legacy_agt_builder(&path).unwrap());
}

#[test]
fn preserves_a_legacy_agt_builder_with_different_line_endings() {
let target = tempdir().unwrap();
let path = target.path().join(LEGACY_AGT_BUILDER_FILE_NAME);
let fixture = include_str!("../../test-fixtures/legacy-agt-builder.md");
fs::write(&path, fixture.replace('\n', "\r\n")).unwrap();

assert!(!is_known_legacy_agt_builder(&path).unwrap());
}

#[test]
fn preserves_a_modified_legacy_agt_builder() {
let target = tempdir().unwrap();
let path = target.path().join(LEGACY_AGT_BUILDER_FILE_NAME);
let fixture = include_str!("../../test-fixtures/legacy-agt-builder.md");
fs::write(&path, fixture.replacen("name:", "# user note\nname:", 1)).unwrap();

assert!(!is_known_legacy_agt_builder(&path).unwrap());
}

#[test]
fn treats_existing_user_agent_as_already_handled() {
let source = tempdir().unwrap();
Expand Down
59 changes: 59 additions & 0 deletions src-tauri/test-fixtures/legacy-agt-builder.md

Large diffs are not rendered by default.

83 changes: 83 additions & 0 deletions src/features/agents/agent-snapshot/mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ describe("snapshot mappings", () => {
profile: {
displayName: "Builder",
about: null,
goodFor: null,
vibes: null,
avatarDataUrl: null,
avatarUrl: null,
},
Expand All @@ -132,6 +134,87 @@ describe("snapshot mappings", () => {
);
});

it.each([
"Agent",
"Draft",
" agent ",
])("does not export placeholder description %j", (sourceDescription) => {
expect(
personaToSnapshot(persona({ sourceDescription })).profile?.about,
).toBeNull();
});

it("round-trips the reviewed public description", () => {
const exported = personaToSnapshot(
persona({ sourceDescription: "Builds useful things." }),
);
expect(snapshotToCreatePersonaRequest(exported).description).toBe(
"Builds useful things.",
);
});

it("bounds public descriptions by grapheme without failing export", () => {
const description = "😀".repeat(120);
const exported = personaToSnapshot(
persona({ sourceDescription: description }),
);
expect(exported.profile?.about).toBe("😀".repeat(110));
expect(snapshotToCreatePersonaRequest(exported).description).toBe(
"😀".repeat(110),
);
});

it("accepts long v1 descriptions and bounds the imported presentation copy", () => {
const value = snapshot({
profile: {
displayName: "Display name",
about: "a".repeat(200),
},
});
expect(snapshotToCreatePersonaRequest(value).description).toBe(
"a".repeat(110),
);
});

it("round-trips grapheme-bounded Unicode share-card metadata", () => {
const goodFor = "👨‍👩‍👧‍👦".repeat(44);
const vibes = "😀".repeat(32);
const exported = personaToSnapshot(persona({ goodFor, vibes }));
expect(snapshotToCreatePersonaRequest(exported)).toMatchObject({
goodFor,
vibes,
});
});

it.each([
[{ legacy: true }, ["calm"]],
["x".repeat(4_097), "y".repeat(4_097)],
["😀".repeat(45), "😀".repeat(33)],
])("ignores incompatible v1 card metadata without rejecting the snapshot", (goodFor, vibes) => {
const value = snapshot({
profile: {
displayName: "Display name",
goodFor: goodFor as string,
vibes: vibes as string,
},
});

expect(snapshotToCreatePersonaRequest(value)).not.toMatchObject({
goodFor: expect.anything(),
vibes: expect.anything(),
});
});

it("round-trips short share-card metadata", () => {
const exported = personaToSnapshot(
persona({ goodFor: "building useful tools", vibes: "sharp, practical" }),
);
expect(snapshotToCreatePersonaRequest(exported)).toMatchObject({
goodFor: "building useful tools",
vibes: "sharp, practical",
});
});

it("exports safe URL and data URL avatars only", () => {
expect(
personaToSnapshot(persona({ avatar: "https://example.com/a.png" }))
Expand Down
33 changes: 32 additions & 1 deletion src/features/agents/agent-snapshot/mapping.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { CreatePersonaRequest, Persona } from "@/shared/types/agents";
import { getRealPersonaDescription } from "@/features/agents/lib/personaPresentation";
import { truncateCardGraphemes } from "@/features/agents/ui/share-card/agentShareCardText";
import { graphemeCount } from "@/shared/lib/graphemeCount";
import {
isRemoteAvatarUrl,
isSafePngAvatarDataUrl,
Expand All @@ -10,6 +13,21 @@ import {
validateSnapshotV1,
} from "./schema";

const MAX_CARD_COPY_RAW_LENGTH = 4_096;

function validOptionalCardCopy(
value: unknown,
maxGraphemes: number,
): string | undefined {
if (typeof value !== "string" || value.length > MAX_CARD_COPY_RAW_LENGTH) {
return undefined;
}
const trimmed = value.trim();
return trimmed && graphemeCount(trimmed) <= maxGraphemes
? trimmed
: undefined;
}

export interface SnapshotMappingSupport {
/** Return true only when this exact provider/model can be selected locally. */
supportsConfiguration?: (provider: string, model: string) => boolean;
Expand Down Expand Up @@ -41,6 +59,14 @@ export function snapshotToCreatePersonaRequest(
snapshot.definition.modelProviderId?.trim() || undefined;
request.model = model;
}
const about = snapshot.profile?.about;
if (typeof about === "string" && about.trim()) {
request.description = truncateCardGraphemes(about.trim(), 110);
}
const goodFor = validOptionalCardCopy(snapshot.profile?.goodFor, 44);
const vibes = validOptionalCardCopy(snapshot.profile?.vibes, 32);
if (goodFor) request.goodFor = goodFor;
if (vibes) request.vibes = vibes;
const avatarDataUrl = snapshot.profile?.avatarDataUrl;
if (
typeof avatarDataUrl === "string" &&
Expand All @@ -56,6 +82,7 @@ export function snapshotToCreatePersonaRequest(
/** Creates a deterministic, config-only snapshot without persistent or secret persona metadata. */
export function personaToSnapshot(persona: Persona): SnapshotV1 {
const displayName = persona.displayName.trim();
const authoredDescription = getRealPersonaDescription(persona);
const snapshot: SnapshotV1 = {
format: SNAPSHOT_FORMAT,
version: SNAPSHOT_VERSION,
Expand All @@ -75,7 +102,11 @@ export function personaToSnapshot(persona: Persona): SnapshotV1 {
},
profile: {
displayName,
about: null,
about: authoredDescription
? truncateCardGraphemes(authoredDescription, 110)
: null,
goodFor: persona.goodFor ?? null,
vibes: persona.vibes ?? null,
avatarDataUrl:
typeof persona.avatar === "string" &&
isSafePngAvatarDataUrl(persona.avatar)
Expand Down
4 changes: 4 additions & 0 deletions src/features/agents/agent-snapshot/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const MAX_SNAPSHOT_AVATAR_DATA_URL_LENGTH =
Math.ceil((2 * 1024 * 1024) / 3) * 4 + "data:image/png;base64,".length;
export const MAX_SNAPSHOT_AVATAR_URL_LENGTH = 2_048;
export const MAX_SNAPSHOT_PROVIDER_MODEL_LENGTH = 512;
const MAX_SNAPSHOT_CARD_COPY_RAW_LENGTH = 4_096;

export interface SnapshotV1Definition {
name?: string;
Expand All @@ -35,6 +36,8 @@ export interface SnapshotV1Definition {
export interface SnapshotV1Profile {
displayName?: string;
about?: string | null;
goodFor?: string | null;
vibes?: string | null;
avatarDataUrl?: string | null;
avatarUrl?: string | null;
[field: string]: unknown;
Expand Down Expand Up @@ -152,6 +155,7 @@ export function validateSnapshotV1(value: unknown): SnapshotV1 {
const profile = isRecord(value.profile) ? value.profile : undefined;
if (profile) {
optionalString(profile, "displayName", MAX_SNAPSHOT_NAME_LENGTH);
optionalString(profile, "about", MAX_SNAPSHOT_CARD_COPY_RAW_LENGTH);
optionalString(
profile,
"avatarDataUrl",
Expand Down
Loading