Codumentor logo Codumentor

User Secrets Plugin

The user_secrets plugin stores per-user API keys and tokens encrypted at rest and lets other plugins and the shell tool consume them without exposing the value to the LLM. It is the preferred way to hand GitHub PATs, provider API keys, and similar credentials to a multi-user Codumentor deployment.

Overview

Three delivery modes, all active independently of workspace isolation:

Requirements

Configuration

plugins:
  - module: codumentor.plugins.user_secrets
    class: UserSecretsPlugin
    priority: 3
    args:
      enabled: true
      max_secrets_per_user: 50          # default: 50
      previous_jwt_secrets: []          # for key-rotation grace period

priority: 3 runs the plugin's onContextReady hook before model_profiles (priority 5) so ${secret:…} placeholders in a profile's api_key are resolvable.

Requires the user_secrets:manage RBAC permission, granted to power_user and admin by default.

How secrets reach consumers

ConsumerWhere placeholder livesWhen it resolves
LLM profile api_keymodels.profiles.<name>.api_key (YAML) or per-user profileAIModels.get_chat_model_for_role at each LLM call
MCP remote transport headersplugins.<mcp>.args.servers.<name>.headersRemoteTransport._get_request_headers() per outbound request
Web-search provider api_keyplugins.<websearch>.args.providers.<name>.api_keySearchProvider.get_api_key() per search
Shell tool env varUser binding (TUI)execute_shell at each command — through build_subprocess_env(set_vars=…)

${VAR} env-var substitution in YAML continues to work as before; the syntax is disambiguated by the colon (${secret:…} vs ${VAR}).

Secret requirements ("Needed by plugins")

Because secrets are stored centrally and referenced by name, a user otherwise has to know the exact name a plugin expects (e.g. redmine_api_key). To close that gap, any plugin can declare the secrets it needs, and this pane surfaces them in a Needed by plugins section — each flagged set or not set, with a one-click Add this secret that pre-fills the create form (name + description) for the unset ones.

The declaration is a manifest field, not a hook: it carries no ordering and adds no dependency edge on user_secrets. The flow is:

  1. A plugin lists secret_requirements in its ui_manifest() (see Declaring required secrets), built from codumentor.core.secrets.SecretRequirement.
  2. PluginHost.collect_secret_requirements() pulls them from every loaded plugin and dedups by secret name, merging the declaring plugins (mirrors collect_api_routers).
  3. This plugin's GET /requirements endpoint annotates each with is_set against the user's stored secrets; the web component and TUI pane render the section.

Two plugins ship declarations today: redmine (its configured api_key_secret) and mcp (every ${secret:...} found in a server's headers/env/args — the case that keeps secret storage centralized, since an MCP server has no plugin-owned UI to collect a key, only a placeholder reference).

The plugin reaches its sibling plugins through an opt-in set_plugin_host(host) that PluginHost.add() calls — so the endpoint can aggregate requirements without threading the host through every call site.

Storage

SQLite at {agent.storage_dir}/user_secrets/secrets.db with two tables:

The store is a thin wrapper around codumentor.core.secrets.EncryptedKVStore — the same primitive the model_profiles plugin now uses for its API key field.

Runtime wiring

onContextReady  (priority 3)                onResponsePersist (priority 99)
──────────────────────────────              ──────────────────────────────
  load user.secrets → SecretsRegistry         restore os.environ
  load bindings, resolve to:                  reset _shell_bindings ContextVar
    subprocess_env {ENV_NAME: value}          reset _user_secrets ContextVar
    agent_env {ENV_NAME: value}
  publish via ContextVars
  set os.environ[name] for agent_env entries

Execution strategies (DirectExecutionStrategy, IsolatedExecutionStrategy) read get_shell_bindings() at each execute_shell call and merge subprocess_env into build_subprocess_env(set_vars=…). The agent's visible tool-call arguments never contain the value.

API

Mounted at /ui/plugins/user_secrets/ (requires user_secrets:manage):

MethodPathPurpose
GET/capabilitiesPlugin limits
GET/secretsList user secrets (values omitted)
GET/requirementsSecrets that loaded plugins declare they need, each flagged is_set
POST/secrets/{name}Create or update a secret
PUT/secrets/{name}/renameRename (cascades to bindings)
DELETE/secrets/{name}Delete (cascades bindings)
GET/bindingsList shell bindings
POST/bindings/{name}Create or update a binding
DELETE/bindings/{name}Delete a binding

All CRUD events are emitted through auth.audit.try_log_event.

TUI

Settings → User Secrets pane: a Needed by plugins section (when any plugin declares requirements) at the top, then add/edit/delete secrets (value masked), then add shell bindings that link a secret to an env var. The expose to agent switch enables Mode C for individual bindings. The pane receives the plugin host via set_plugin_host, injected by the settings screen.

Files

FilePurpose
core/secrets/crypto.pyderive_fernet_key, FieldEncryptor (rotation-aware)
core/secrets/store.pyEncryptedKVStore — per-user encrypted K/V
core/secrets/resolver.pySecretsRegistry, ShellBindings, ContextVars, ${secret:…} expansion, referenced_names()
core/secrets/requirements.pySecretRequirement — declarative per-plugin secret needs
plugins/plugin_bus.pyPluginHost.collect_secret_requirements() aggregator + set_plugin_host injection
plugins/user_secrets/plugin.pyPlugin class, hooks, FastAPI router (incl. /requirements)
ui/plugins/user_secrets/src/index.tsx-user-secrets-settings web component (incl. "Needed by plugins")
plugins/user_secrets/bindings_store.pyShell-bindings SQLite CRUD
tui/widgets/settings/user_secrets.pyTUI settings pane
agent/execution_strategy.pyDirectExecutionStrategy injects bindings into subprocess env
plugins/workspace_isolation/strategies.pyIsolatedExecutionStrategy does the same for bwrap

Testing

python -m pytest tests/test_core_secrets.py tests/test_secrets_resolver.py \
                 tests/test_user_secrets_plugin.py tests/test_shell_bindings.py \
                 tests/test_shell_bindings_agent_exposure.py \
                 tests/test_secret_requirements.py -v

See also