Skip to content

feat(desktop): per-persona and per-agent env var overrides#594

Merged
tlongwell-block merged 1 commit into
mainfrom
feat/persona-env-overrides
May 15, 2026
Merged

feat(desktop): per-persona and per-agent env var overrides#594
tlongwell-block merged 1 commit into
mainfrom
feat/persona-env-overrides

Conversation

@tlongwell-block
Copy link
Copy Markdown
Collaborator

@tlongwell-block tlongwell-block commented May 15, 2026

What

Adds per-persona and per-agent env var overrides to the desktop GUI. Personas and managed agents each carry a BTreeMap<String, String> of env vars that get layered into the spawned agent's environment.

Precedence (last wins): desktop parent env < persona env < agent env.

A small set of reserved keys — Sprout's identity and secrets — are rejected at save time and stripped at runtime so a typo or malicious value can't swap the agent's nsec.

Why

Users want to set provider credentials (ANTHROPIC_API_KEY, OPENAI_API_KEY), provider selection (GOOSE_PROVIDER), local-LLM endpoints (OPENAI_BASE_URL), etc. without editing shell rc files. Per-persona keys cover the common case; per-agent overrides handle the "one-off agent on a different model" case.

Surface area

Backend (Rust):

  • managed_agents/env_vars.rs — new module with merged_user_env, RESERVED_ENV_KEYS, is_reserved_env_key, validate_user_env_keys + 16 unit tests.
  • managed_agents/runtime.rs — merges persona env → agent env, calls into env_vars::merged_user_env at spawn.
  • managed_agents/types.rsenv_vars field on PersonaRecord and ManagedAgentRecord.
  • commands/personas.rs, commands/agents.rs, commands/agent_models.rsvalidate_user_env_keys at save time; merged_user_env also used by sprout-acp models discovery and provider deploy so credentials in persona env flow into both.

Frontend (TS):

  • features/agents/ui/EnvVarsEditor.tsx — new shared component (key/value rows, add/remove, validation hint).
  • features/agents/ui/PersonaDialog.tsx, CreateAgentDialog.tsx, EditAgentDialog.tsx — embed the editor; persona env shown read-only when editing an agent, with per-agent overrides on top.
  • shared/api/types.ts, tauri.ts, tauriPersonas.tsenvVars field on create/update payloads, with the convention that envVars: undefined on update = "don't touch" (so editing unrelated fields can't wipe saved credentials).

Reserved keys

Key Why
SPROUT_PRIVATE_KEY Agent's nsec — overriding swaps identity
NOSTR_PRIVATE_KEY Mirror of above for git-credential-nostr
SPROUT_AUTH_TAG NIP-OA auth tag — overriding breaks relay auth
SPROUT_API_TOKEN Reserved for future server auth
SPROUT_ACP_PRIVATE_KEY Legacy alias for SPROUT_PRIVATE_KEY
SPROUT_ACP_API_TOKEN Legacy alias for SPROUT_API_TOKEN

Behavior knobs (GOOSE_MODE, SPROUT_TOOLSETS, SPROUT_ACP_MODEL, SPROUT_ACP_SYSTEM_PROMPT, etc.) remain freely overridable — those have dedicated UI fields, but power users may want to bypass them.

Two-layer enforcement:

  1. Save-timevalidate_user_env_keys rejects reserved keys with a clear error like the following env vars are reserved by Sprout and cannot be overridden: SPROUT_PRIVATE_KEY, surfaced in the dialog.
  2. Runtimemerged_user_env strips reserved keys with a warning log. Defense in depth for older on-disk records that predate validation.

Case-insensitive match so sprout_private_key is also rejected — Unix env vars are case-sensitive at the syscall level, but lowercase variants of these specific keys are almost certainly typos.

Tests

  • 16 unit tests in env_vars.rs cover the merge order, the reserved-key filter, and the validator.
  • 1 e2e test (desktop/tests/e2e/persona-env-vars.spec.ts) drives the EnvVarsEditor through the Persona dialog.
  • All 291 desktop crate unit tests pass. cargo fmt, biome check, pnpm typecheck, cargo clippy all clean.

Review history

Codex reviewed five times across the branch lifecycle:

  • R1: caught HIGH (runtime merge order — sort of, see note), HIGH (None=clear semantics — withdrew that design, simpler last-wins), MED (model discovery wouldn't see persona env — fixed), MED (summary plumbing — fixed).
  • R2: dialog state clear/preserve nits.
  • R3: edit-dialog initial-state seeding.
  • R4: aborted (pre-fix codex timeout).
  • R5: clean — "The changes compile and typecheck, and the new env var data flow appears consistent across persona/agent storage, UI editing, local spawn, model discovery, and provider deploy paths. I did not find any discrete correctness issues introduced by this patch." + one lint nit (hasOwnPropertyin), fixed.

The final fix in this PR (reserved-key denylist) closes a footgun I called out in self-review but the earlier passes didn't flag: user env was written LAST during spawn, so a GUI typo of SPROUT_PRIVATE_KEY would have swapped the agent's identity at runtime. The "no allow-list, by design" comment was wrong on its own terms — fixed.

Supersedes

#576 (already closed).

@tlongwell-block tlongwell-block force-pushed the feat/persona-env-overrides branch 9 times, most recently from a552fa8 to 43e6185 Compare May 15, 2026 13:06
Adds env_vars: BTreeMap<String, String> on both PersonaRecord and
ManagedAgentRecord. Precedence at spawn: parent env < persona < agent
(last write wins). Reserved keys — Sprout's identity, code-execution
surface, and security gates — are rejected at save time and stripped at
runtime so a typo or malicious value can't swap the agent's nsec, code,
relay, or respond-to gate.

Backend

- managed_agents/env_vars.rs (new): merged_user_env, RESERVED_ENV_KEYS,
  is_reserved_env_key, validate_user_env_keys, 20 unit tests.
- managed_agents/runtime.rs: merges persona env → agent env at spawn
  via env_vars::merged_user_env.
- managed_agents/types.rs: env_vars field on PersonaRecord and
  ManagedAgentRecord; included in ManagedAgentSummary so the frontend
  list/edit reload sees saved state.
- managed_agents/backend.rs: extends provider stderr/error redaction to
  scrub user-supplied env values (redact_secrets_with +
  env_secrets_from_request + redact_env_values_in). Uses single-pass
  str::replace to avoid a non-terminating loop when a user env value is
  a substring of '[REDACTED]'.
- commands/personas.rs, commands/agents.rs, commands/agent_models.rs:
  validate_user_env_keys at create/update; merged_user_env also used by
  sprout-acp models discovery and provider deploy so credentials in
  persona env flow into both. Model-discovery stderr is now redacted
  through redact_env_values_in before being formatted into the
  frontend-visible error.

Reserved keys (three categories)

- Identity / secrets: SPROUT_PRIVATE_KEY, NOSTR_PRIVATE_KEY,
  SPROUT_AUTH_TAG, SPROUT_API_TOKEN, SPROUT_ACP_PRIVATE_KEY,
  SPROUT_ACP_API_TOKEN.
- Code-execution surface: SPROUT_ACP_AGENT_COMMAND, SPROUT_ACP_AGENT_ARGS,
  SPROUT_ACP_MCP_COMMAND, SPROUT_RELAY_URL.
- Security gates: SPROUT_ACP_RESPOND_TO, SPROUT_ACP_RESPOND_TO_ALLOWLIST,
  SPROUT_ACP_AGENT_OWNER (legacy-record owner fallback).

Case-insensitive match — lowercase variants of these specific keys are
almost certainly typos, not legitimate use. Behavior knobs (GOOSE_MODE,
SPROUT_TOOLSETS, SPROUT_ACP_MODEL, SPROUT_ACP_SYSTEM_PROMPT) remain
freely overridable.

Two-layer enforcement:
1. Save-time — validate_user_env_keys rejects reserved keys with a
   clear error listing the offending keys, surfaced in the dialog.
2. Runtime — merged_user_env strips reserved keys with a warning log.
   Defense in depth for older on-disk records that predate validation.

Frontend

- features/agents/ui/EnvVarsEditor.tsx (new): reusable key/value editor
  with add/remove rows and validation hints.
- PersonaDialog, CreateAgentDialog, EditAgentDialog: embed the editor.
- shared/api/types.ts, tauri.ts, tauriPersonas.ts: envVars field on
  create/update payloads. envVars: undefined on update = 'don't touch'
  so editing unrelated fields can't wipe saved credentials.

Tests

- 20 unit tests in env_vars.rs covering merge precedence, reserved-key
  stripping for each category (identity, code-execution, security gates,
  legacy owner, relay URL, case-insensitive), and the validator.
- 6 new unit tests in backend.rs covering redact_secrets_with (user env
  values scrubbed, short values skipped, overlapping secrets handled,
  termination when value is substring of marker), env_secrets_from_request
  (string extraction + missing-shape), and redact_env_values_in.
- 1 e2e test (desktop/tests/e2e/persona-env-vars.spec.ts) drives the
  EnvVarsEditor through the Persona dialog.
- All 307 desktop crate unit tests pass.

Also bumps the MessageComposer.tsx size limit from 700→710 (the
composer-autofocus PR #572 landed on origin/main mid-review and pushed
it 3 lines over). Bumps backend.rs limit from 530→700 for the new
redaction helpers + their tests.

Supersedes #576.

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>

Security: reject malformed env var keys

Tightens validate_user_env_keys to require POSIX-shaped keys
([A-Za-z_][A-Za-z0-9_]*). Closes a denylist bypass: Rust's
Command::env(k, v) accepts a key containing '=' and writes it verbatim
into the child's environ block. A key like SPROUT_AUTH_TAG=x with value
forged lands as SPROUT_AUTH_TAG=x=forged in the child env, so
getenv("SPROUT_AUTH_TAG") returns "x=forged" — bypassing the reserved-
key string compare. Confirmed exploitable for SPROUT_AUTH_TAG (legacy
agents) and any other reserved key Sprout doesn't always set with the
canonical name first.

Two-layer fix:
1. validate_user_env_keys rejects malformed keys at save time, listing
   each invalid key with a clear regex hint.
2. merged_user_env strips malformed keys at spawn time as defense in
   depth for on-disk records that predate the validator.

+6 unit tests pinning the bypass and adjacent edge cases (empty,
whitespace, NUL, leading digit, non-ASCII, =-in-key). 313 unit tests
pass.
@tlongwell-block tlongwell-block force-pushed the feat/persona-env-overrides branch from 43e6185 to a51b689 Compare May 15, 2026 13:08
@tlongwell-block tlongwell-block merged commit 32347d1 into main May 15, 2026
15 checks passed
@tlongwell-block tlongwell-block deleted the feat/persona-env-overrides branch May 15, 2026 14:43
tlongwell-block added a commit that referenced this pull request May 15, 2026
* origin/main: (33 commits)
  dev-mcp: add view_image tool (#602)
  fix(relay,desktop): only advertise NIP-43 when enforced; probe pairing by supported_nips (#601)
  fix(desktop): derive unread state from NIP-RS + relay catch-up only (#599)
  docs(testing): rewrite TESTING.md for current API and CLI-first workflow (#597)
  fix(agent): fix OpenAI-compat request body serialization and max_tokens (#595)
  feat(desktop): per-persona and per-agent env var overrides (#594)
  fix(desktop): stop pinning agents to deprecated SPROUT_ACP_TURN_TIMEOUT (#592)
  fix(desktop): populate member_count in get_channels so channel browser shows real counts (#548)
  fix(desktop): autofocus message composer on channel/thread open (#572)
  refactor(cli): restructure flat commands into 12 subcommand groups (#585)
  feat(sdk): add builder functions for workflows, DMs, and presence (#589)
  feat(desktop): add message more-actions dropdown menu (#590)
  fix(mobile): preserve channel list across background/resume reconnection (#588)
  Redesign Home as an inbox (#582)
  fix(desktop): drive unread badges from live subscription, not refetched lastMessageAt (#581)
  fix(desktop): refine header scaling and shadow (#573)
  fix(desktop): keep day dividers below header (#574)
  Move agent activity below composer (#579)
  docs(nips): NIP-AE — Agent Engrams (#575)
  refactor: extract shared @mention resolver into sprout-sdk (#580)
  ...

Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
tlongwell-block added a commit that referenced this pull request May 15, 2026
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>

* origin/main:
  dev-mcp: add view_image tool (#602)
  fix(relay,desktop): only advertise NIP-43 when enforced; probe pairing by supported_nips (#601)
  fix(desktop): derive unread state from NIP-RS + relay catch-up only (#599)
  docs(testing): rewrite TESTING.md for current API and CLI-first workflow (#597)
  fix(agent): fix OpenAI-compat request body serialization and max_tokens (#595)
  feat(desktop): per-persona and per-agent env var overrides (#594)
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.

1 participant