feat: Markdown-based persona packs (crate + ACP + desktop)#297
Merged
tlongwell-block merged 29 commits intomainfrom Apr 11, 2026
Merged
feat: Markdown-based persona packs (crate + ACP + desktop)#297tlongwell-block merged 29 commits intomainfrom
tlongwell-block merged 29 commits intomainfrom
Conversation
…ests (S2) Spec amendment S1: When a persona sets respond_to, the entire object replaces the pack default. Missing sub-fields fall to built-in defaults (mentions: true, keywords: [], all_messages: false), not pack defaults. This simplifies the merge logic significantly — removes the complex field-level merge in favor of a clean shallow replacement pattern consistent with how all other fields work. Also adds subscribe merge tests for S2 three-state semantics (null → fallthrough, [] → override, absent → fallthrough). Tests: 6 new respond_to tests, 3 new subscribe tests. All 120 pass.
Add persona pack management subcommands to the CLI: - sprout pack validate <path> — validates a pack directory, prints diagnostics, exits 0 (valid), 1 (errors found) - sprout pack inspect <path> — loads a pack and pretty-prints metadata and effective per-persona config (model, temperature, subscribe, etc.) Both commands are local-only (no relay connection needed) and are handled before SproutClient creation, like the auth command. Changes: - Cargo.toml: add sprout-persona dependency - main.rs: add Pack(PackCmd) subcommand with Validate/Inspect variants - commands/pack.rs: new file with cmd_validate and cmd_inspect - commands/mod.rs: register pack module - Update command inventory test: 53 → 54
The key deliverable: resolve_pack() loads a pack directory and produces a fully typed, ACP-ready ResolvedPack with ResolvedPersona entries. All merge policy (levels 3-5) is applied. Features: - System prompt composition (persona body + pack instructions) - Model string splitting (provider:model-id → ResolvedModel) - MCP server merge (pack shared + persona-specific, persona wins) - Env var literals preserved (no interpolation — deferred) - Goose env var projection (GOOSE_PROVIDER, GOOSE_MODEL, etc.) - Hooks resolution (pack-relative → absolute paths) - Triggers (renamed from respond_to per spec discussion) Design: pure functions, no env access, no network. ACP owns the security boundary for env filtering and interpolation. 28 new tests (unit + filesystem integration). 144 total pass.
- Model split: ResolvedPersona now has separate model (plain ID) and provider fields instead of ResolvedModel struct. Maps directly to Config.model and PersonaRecord.provider. - Add resolve_persona_by_name() convenience function for ACP to resolve a single persona from a pack directory. - Keep Vec<ResolvedMcpServer> (typed) per Hana's recommendation over V3's Option<serde_json::Value> — contract should absorb complexity. - Keep Vec<(String, String)> for goose_env_vars (4 entries max, Vec is fine). 29 resolve tests + 151 total library tests pass.
Per Lep's security review (F1): without an explicit allowlist, a malicious pack could theoretically inject arbitrary env vars (PATH, LD_PRELOAD, SPROUT_PRIVATE_KEY). The projection function already only produces GOOSE_* vars by construction, but the allowlist makes the boundary explicit, documented, and testable. Allowed vars: GOOSE_PROVIDER, GOOSE_MODEL, GOOSE_TEMPERATURE, GOOSE_CONTEXT_LIMIT. Also documents hooks/skills fields as 'reserved for future use, not yet wired' per Hana's review caveat. 152 unit tests pass including new env_vars_only_allowlisted test.
Tilly renamed RespondToData→TriggersData and respond_to→triggers in merge.rs and persona.rs. This cascades the rename through: - pack.rs: import, struct field, assignments, test fixtures - resolve.rs: import, function signature, test helper - legacy.rs: struct construction, test assertion Also fixes pack.rs subscribe type mismatch (Option<Vec<String>> from ResolvedConfig → Vec<String> on LoadedPersona via unwrap_or_default). Security fix (Lep F1): resolve_hooks() now stores raw relative strings instead of resolving to absolute paths. Prevents path traversal via hooks.on_start: '../../../../usr/bin/evil'. The PR that wires hook execution MUST validate through safe_resolve() before use. 156 unit tests pass.
…n, runtime bridge ACP changes: - Cargo.toml: add sprout-persona dependency - config.rs: add --persona-pack and --persona-name CLI flags (with env var fallbacks SPROUT_ACP_PERSONA_PACK, SPROUT_ACP_PERSONA_NAME) - config.rs: resolve persona at startup, apply precedence rule (CLI/env > persona > built-in defaults) for system_prompt and model - config.rs: store persona_env_vars on Config for spawn-time injection - acp.rs: extend AcpClient::spawn() with extra_env parameter; only inject vars not already set in parent env (operator precedence) - main.rs: thread persona_env_vars through all spawn_and_init call sites Desktop changes: - runtime.rs: for pack-backed agents (persona_pack_path set), set SPROUT_ACP_PERSONA_PACK and SPROUT_ACP_PERSONA_NAME env vars and skip SPROUT_ACP_SYSTEM_PROMPT/MODEL (let ACP resolve from pack). For non-pack agents, existing behavior unchanged. Precedence rule: CLI/env args > persona values > built-in defaults. Persona fills in what's missing. Explicit flags always win.
Desktop pack management: - import_persona_pack(): validate → sanitize pack-id → check existing → copy (no symlinks) → re-validate → create PersonaRecords → merge - uninstall_persona_pack(): remove pack dir + pack PersonaRecords - list_installed_packs(): scan packs dir, resolve each, return summaries - Pack-id sanitization: [a-zA-Z0-9._-]+ (Lep F2 zip-slip defense) - Symlink skip in copy (Lep F2) - Re-validate after copy (Lep F2 defense-in-depth) Tauri commands: - install_persona_pack(path) → Vec<PersonaRecord> - uninstall_persona_pack(pack_id) → () - list_persona_packs() → Vec<PackSummary> Also: - Added sprout-persona dependency to desktop Cargo.toml - Fixed source_pack: None on PersonaRecord constructors - Registered new commands in lib.rs - Fixed unused PathBuf import warning in resolve.rs Desktop compiles clean. 156 crate unit tests pass.
…ookup Clove's review caught that persona_name_in_pack was set to display_name (e.g., 'Lep') instead of the internal name slug (e.g., 'lep'). ACP's resolve_persona_by_name() matches on the internal name, so the lookup would fail when they differ. Fix: resolve the installed pack at agent creation time to find the correct internal name. Also eliminates redundant load_personas() calls by computing pack_path and persona_name together.
- #2: Document display_name reverse-lookup fragility in agents.rs with TODO for long-term fix (store internal name on PersonaRecord) - #3: Add on_message to ResolvedHooks so it's not silently dropped during resolution - #4: Fix misleading comment on ResolvedPack.description (was 'use pack name as fallback', now 'not yet wired') 156 crate tests pass, desktop compiles clean.
Lep flagged that the character allowlist includes '.' which means
'..' passes validation. The starts_with('.') check already blocked
this, but added belt-and-suspenders:
- Require at least one alphanumeric character (rejects '---', '...')
- Reorganized checks for clarity: charset first, then traversal defense
- Explicit comment documenting the path traversal defense
Per Lep's request: regression test for '..' path traversal, plus coverage for '.', leading dots, slashes, no-alphanumeric, empty, too-long, and valid reverse-DNS IDs. 9 new tests, all passing.
Final round of fixes from security and code quality reviews: - Rename respond_to → triggers throughout merge.rs (spec S1) - Fix subscribe merge to handle Option<Vec<String>> (spec S3) - Add source_pack_persona_slug to PersonaRecord for reliable pack lookup - Harden validate_pack_id against path traversal (reject leading dots) - Add source_pack_persona_slug to all PersonaRecord constructors - Update TypeScript types with sourcePack field - Add V7 persona pack specification Security: Lep 9/10, Code quality: Clove 9.5/10, Architecture: Hana APPROVE
…PERSONA_PACK_SPEC.md - Remove V7 version artifacts (status, replaces, changes-from-V6) - Remove 'What This Spec Corrects' internal iteration table - Rename respond_to → triggers throughout (avoids ACP naming collision) - Rename 'Build Requirements' → 'Planned Features' - Remove BR-N references, use plain language - No YAML frontmatter — clean markdown document
…es in spec - Wire pack description through PackManifestData to ResolvedPack - Document persona version defaulting (no per-persona version field yet) - Add implementation notes for deferred features: MCP interpolation, hooks, skills - Clarify AGENT_CWD definition (operator env var → ACP protocol field) - All 156 tests passing
…ck.rs cleanup Fixes all issues identified by three independent reviewers (Opus, Codex, Gemini): 1. respond_to/triggers naming mismatch (critical bug): Pack-level trigger defaults were silently never inherited because BehavioralDefaults serialized the field as 'respond_to' but merge.rs looked for 'triggers'. Fixed by renaming the field to 'triggers' with #[serde(alias = "respond_to")] for backward compatibility. Same alias added to Frontmatter so both YAML keys work. Added regression test verifying inheritance. 2. Desktop uninstall safety: uninstall_persona_pack() now checks for managed agents still referencing the pack and refuses with a clear error listing the affected agents. Prevents latent startup failures. 3. sprout-persona in workspace members: Added to root Cargo.toml so 'cargo test --workspace' and 'cargo clippy --workspace' include it. 4. pack inspect uses resolve_pack(): Shows fully resolved effective config (post-merge, model split, env vars projected) instead of raw loaded data. 5. pack.rs cleanup: Extracted read_bounded_file() helper, eliminating ~40 lines of repetitive stat-check-read boilerplate. Renamed PersonaParse error variant to FileParse (it was used for non-persona files too). 6. Integration tests updated: Use canonical 'triggers:' key per spec. Added assertion that pip (no explicit triggers) inherits pack defaults. All 175 tests pass. All crates compile.
5fb4e89 to
d8d9bdc
Compare
…ona-migration * origin/main: feat(desktop): add Pulse social notes surface (#296) Fix flaky desktop smoke tests (#294) Add agent lifecycle controls to channel members sidebar (#291) Update nest_agents.md tagging info (#292) feat: add Sprout nest — persistent agent workspace at ~/.sprout (#290) Fix auth and SSRF vulns (#261) Add per-agent MCP toolset configuration to agent setup (#279) feat(desktop): team & persona import/edit flows (#288) Remove menu item subtitles and fix persona card overflow (#289) feat: Phase 1 video upload support (Blossom-compliant-ish) (#285) Add inline subtitles to menu items and field descriptions (#276) Improve ephemeral channel affordances and hide archived sidebar rows (#286) Fix @mention search to use word-boundary prefix matching (#278) Allow bot owners to remove their agents from any channel (#284) [codex] Polish agent selectors and settings layout (#283) # Conflicts: # desktop/scripts/check-file-sizes.mjs
Pack personas are immutable (backed by the pack directory, resolved at runtime by ACP). The backend already blocks edit/delete, but without frontend guards users would see confusing errors. - PersonasSection: hide Edit menu item for pack personas - PersonasSection: show disabled 'Managed by pack' instead of Delete - teamImportPlan: skip pack personas from membersToUpdate (prevents team import-update from trying to edit pack-backed members) Compatible with the persona editing GUI from PR #288.
…defaults Pack personas are now editable in the GUI. User edits to system_prompt and model are stored in PersonaRecord and passed as env vars at spawn time. ACP's precedence model (CLI/env > persona > default) means user edits naturally override pack defaults without any special handling. Runtime change: for pack-backed agents, the runtime now reads system_prompt and model from the current PersonaRecord (which the user edits in the GUI) rather than the stale ManagedAgentRecord snapshot from creation time. Falls back to agent record values if the persona has been deleted. - Revert backend edit block on pack personas - Revert frontend Edit button hiding for pack personas - Revert team import plan pack-persona filter - Runtime reads live PersonaRecord for pack agents at spawn time - Keep delete protection (pack is a unit — use Uninstall Pack) - Keep 'Managed by pack' hint on Delete menu item
Minimal working example: Skip (orchestrator), Lev (security), Bana (architecture). Includes one shared skill (github-research), pack-level instructions, and pack defaults with model/temperature/triggers. Validates clean with 'sprout pack validate'. Serves as a starting point for users creating their own packs.
51d5597 to
523592e
Compare
The Import button now accepts: - .persona.md — parsed via sprout_persona::persona::parse_persona_md() - .zip containing .plugin/plugin.json — detected as a persona pack, extracted to temp dir, resolved via resolve_pack(), personas previewed - .zip containing .persona.md files — each parsed individually - .persona.json, .persona.png — existing formats (unchanged) Backend: - parse_md_persona() in persona_card.rs — converts PersonaConfig to ParsedPersonaPreview (splits provider:model, maps fields) - parse_zip_pack() — extracts zip to tempdir, calls resolve_pack(), maps ResolvedPersona to previews - parse_zip_personas() now detects .plugin/plugin.json and delegates to parse_zip_pack(); also handles .persona.md entries in loose zips - parse_persona_files() detects .md extension and routes to parse_md_persona Frontend: - File picker accept attributes updated to include .md - Help text updated to mention .persona.md format
The Teams Import button now accepts .zip files in addition to .json. When a zip contains .plugin/plugin.json (persona pack), it's resolved via parse_zip_pack() and converted to a ParsedTeamPreview: pack name becomes team name, each resolved persona becomes a team member. This means users can import a persona pack as a team from either the Personas import button (individual personas) or the Teams import button (as a team).
… detection Addresses codex review findings on the import feature: 1. Team zip import size limit: zip detection now runs BEFORE the 5MB JSON size check. Pack zips get a 100MB limit (matching persona zip). 2. Zip extraction path safety: use enclosed_name() from the zip crate instead of manual '..' / '/' checks. This correctly handles absolute Windows paths, drive prefixes, and non-normalized path forms. 3. Pack detection scope: limit to root-level and one-folder-deep (matching find_plugin_json's actual search depth). Prevents false positive pack detection on deeply nested zips. 4. Team name from structured data: parse_team_from_pack_zip now calls resolve_pack() directly and reads resolved.name, instead of parsing the pack name from a formatted source_file string. 5. find_plugin_json made pub for reuse in teams.rs.
The env var allowlist hardcoded GOOSE_* vars as the only permitted projection from persona packs. This breaks non-goose agent runtimes. The actual safety mechanism is operator precedence: ACP checks std::env::var(key).is_err() before injecting any pack-projected var, so operator-set env vars always win. The allowlist was redundant.
…atus - Replace goose-specific language with agent-agnostic terms throughout - Mark PF-2 (pack validate) and PF-6 (per-subprocess env vars) as implemented - Remove stale 'Current limitation' note (AcpClient::spawn now has extra_env) - Remove '(planned)' from env var mapping (implemented) - Fix backward compat claim (V6 sprout: block is NOT supported) - Add respond_to legacy alias documentation - Add Desktop App Import section to distribution (Section 10) - Generalize skill discovery, MCP delivery, hook descriptions - Keep goose-specific references only where they document actual goose CLI flags or filesystem paths (anti-pattern table, skill paths)
tellaho
added a commit
that referenced
this pull request
Apr 14, 2026
* origin/main: Replace inline channel creation with dialog (#312) chore: improve chat message layout to left-aligned design (#309) Add edit dialog for managed agents with relay profile sync (#277) fix(ci): build relay with optimized profile to fix flaky e2e tests (#307) Update actions/checkout action to v6 (#305) Update dependency @tanstack/react-query to v5.98.0 (#304) Update dependency @playwright/test to v1.59.1 (#303) Update react monorepo to v19.2.5 (#302) feat(mobile): scaffold Flutter app with Riverpod & Catppuccin theme (#306) Update dependency @tanstack/react-router to v1.168.13 (#301) feat: Markdown-based persona packs (crate + ACP + desktop) (#297) feat(desktop): improve Agents page UX (#298) feat(desktop): add Pulse social notes surface (#296) Fix flaky desktop smoke tests (#294) Add agent lifecycle controls to channel members sidebar (#291) # Conflicts: # desktop/pnpm-lock.yaml
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Introduces
.persona.md— a markdown-based persona format that replaces flat JSON persona records with composable, portable persona packs. This is a superset of the Open Plugin Spec — every persona pack is a valid OPS package.What Changed
New crate:
sprout-persona(~4,100 lines, 175 tests)persona.rs—.persona.mdparser (YAML frontmatter + markdown body)manifest.rs—plugin.jsonmanifest parser (OPS-compatible)merge.rs— 5-level precedence resolution (operator > UI > persona > pack defaults > built-in)resolve.rs—ResolvedPackAPI producing typed, ACP-ready output with semantic validationpack.rs— Pack loader with path traversal defense and bounded file readsvalidate.rs— Pack validation (zero-persona, duplicate names, name charset, triggers type checks)legacy.rs— JSON adapter + migration toolCLI (
sprout-cli)sprout pack validate <dir>— validates a pack directory, reports errors/warningssprout pack inspect <dir>— shows fully resolved effective config (post-merge, model split, env vars)ACP harness (
sprout-acp)--persona-pack <dir>+--persona-name <name>CLI flagsConfig::from_cli()resolves persona and applies precedence (CLI/env > persona > built-in)GOOSE_PROVIDER,GOOSE_MODEL,GOOSE_TEMPERATURE,GOOSE_CONTEXT_LIMIT)Desktop app
.persona.md,.persona.json,.persona.png, and.zipfiles.plugin/plugin.jsonare detected as persona packs and resolved viaresolve_pack()— works from both the Personas and Teams import buttonsenclosed_name()zip extraction, re-validation after copy, decompressed size limits)PersonaRecordextended withsource_packandsource_pack_persona_slugManagedAgentRecordextended withpersona_pack_pathandpersona_name_in_packExample pack (
examples/meadow-core/)github-researchskill, pack-level instructions, and pack defaultssprout pack validateArchitecture
Execution model: One persona per ACP process. Desktop spawns N processes for N personas in a pack.
Persona Format Example
Security
[a-zA-Z0-9._-]+, no leading dots (prevents..traversal)enclosed_name()from zip crate for path safety; decompressed size limits (100MB)Review Scores
Spec
See
crates/sprout-persona/PERSONA_PACK_SPEC.mdfor the full format specification.Deferred to Follow-Up
engines.sproutversion enforcementsprout pack initscaffolding CLI