Date: 2026-02-19
Issue: Agent asks users to paste API keys in chat + bash tool not loading custom keys from Settings
When the agent needed an API key that wasn't configured (e.g., GITHUB_TOKEN), it would ask the user to paste the key directly in the chat:
Agent: "I need a GitHub token to continue. Please paste your token here."
This is insecure because:
- Keys exposed in plaintext in chat history
- Keys not encrypted in macOS Keychain
- Keys not reusable across sessions/jobs
- No permission controls
Even after user added keys in Settings, bash commands would fail:
// User adds GITHUB_TOKEN in Settings
bash({ command: 'curl -H "Authorization: Bearer ${GITHUB_TOKEN}" ...' })
// ❌ Error: "Token not found in environment"
// ❌ Agent: "The token isn't in the shell environment"Root Cause: Bash tool was ONLY checking process.env, NOT CustomKeysStorage where user-configured keys are stored.
The system prompt had conflicting instructions:
What it said:
**NEVER ask users to paste API keys or secrets in chat!** Use the built-in key management system.
What was missing:
- No workflow for when a key doesn't exist yet
- No mention of Settings UI
- No instructions to call
list_keysfirst - No example of what to tell the user
Result: Agent followed "don't ask in chat" but had no alternative, so it asked anyway.
The solution has three critical fixes - inline key request (best UX), Settings UI fallback, and bash tool integration:
Added request_key tool that shows an inline input card in the chat:
Tool Definition:
export const requestKeyTool = createTool({
id: "request_key",
description: "Request a missing API key with an inline input card...",
inputSchema: z.object({
name: z.string(), // e.g., "GITHUB_TOKEN"
description: z.string(), // What it's for
sourceUrl: z.string().optional(), // Where to get it
requiredScopes: z.array(z.string()).optional(), // API permissions
permission: z.enum(["always", "ask"]).default("ask")
}),
execute: async (args) => {
// Returns special format that UI recognizes
return {
success: true,
data: {
type: "key_request", // Signals UI to show input card
name: args.name,
description: args.description,
sourceUrl: args.sourceUrl,
requiredScopes: args.requiredScopes,
suggestedPermission: args.permission,
status: "awaiting_user_input"
}
};
}
});UI Component: KeyRequestCard.tsx
- Inline card that appears in chat
- Password input with show/hide toggle
- Permission controls (always allow vs. ask each time)
- Link to key generation page (opens in browser)
- Submits to CustomKeysService → Electron IPC → Keychain
Added workflow instructions to SystemPrompt.ts:
New Section: "When You Need a Key That Doesn't Exist"
## When You Need a Key That Doesn't Exist
**Primary Method: request_key tool (Best UX)**
1. Check what keys exist first: Call `list_keys`
2. Request the key inline: Call `request_key` with metadata
3. User enters key in inline card (never leaves chat)
Example:
request_key({
name: "GITHUB_TOKEN",
description: "GitHub API access for fetching repository data",
sourceUrl: "github.com/settings/tokens",
requiredScopes: ["repo", "read:user"],
permission: "always"
})
**Alternative: Direct to Settings UI**
If request_key fails or for batch key setup:
"I'll need a GitHub token. Please:
1. Go to Settings → API Keys → Custom API Keys
2. Add a new key:
- Name: GITHUB_TOKEN
- Value: Your token from github.com/settings/tokens
- Permission: 'Always Allow' (for automation)
3. Let me know when you've added it!"Before (Broken):
// src/core/tools/bash.ts
// Build custom keys map from environment
const customKeys: Record<string, string> = {};
for (const key of apiKeys) {
const keyName = Object.keys(process.env).find(
(k) => process.env[k] === key
);
if (keyName) {
customKeys[keyName] = key;
}
}
// ❌ ONLY checks process.env - misses custom keys from Settings!After (Fixed):
// Build custom keys map from environment AND CustomKeysStorage
const customKeys: Record<string, string> = {};
// 1. Add keys from environment
for (const key of apiKeys) {
const keyName = Object.keys(process.env).find(
(k) => process.env[k] === key
);
if (keyName) {
customKeys[keyName] = key;
}
}
// 2. Add keys from CustomKeysStorage (user-configured keys)
try {
const { getCustomKeysService } = await import("../../gateway/services/CustomKeysService.js");
const service = getCustomKeysService();
const storedKeys = await service.listKeys();
// Fetch values for all stored keys
for (const keyMeta of storedKeys) {
const value = await service.getKeyByName(keyMeta.name);
if (value) {
customKeys[keyMeta.name] = value;
// Add to apiKeys array for sanitization
if (!apiKeys.includes(value)) {
apiKeys.push(value);
}
}
}
} catch (error) {
console.warn('[Bash Tool] Failed to load custom keys:', error);
// Continue without custom keys - env vars still work
}Impact:
- ✅ Bash commands now load keys from Settings
- ✅ Keys encrypted in Keychain (secure)
- ✅ Keys work in jobs (not just environment)
- ✅
${GITHUB_TOKEN}substitutes correctly - ✅ Same fix applied to
executeBashCommandStreaming()
See: docs/BASH_TOOL_CUSTOM_KEYS_FIX.md for detailed testing examples.
Step-by-step flow with all three fixes:
-
Agent needs missing key
Agent: [calls list_keys] Result: No GITHUB_TOKEN found -
Agent requests key inline (Fix 1)
Agent: [calls request_key({ name: "GITHUB_TOKEN", description: "GitHub API access for fetching repository data", sourceUrl: "github.com/settings/tokens", requiredScopes: ["repo", "read:user"], permission: "always" })] -
User enters key in inline card
UI: Shows KeyRequestCard inline in chat User: Enters key value (password field with show/hide) User: Selects permission level (always/ask) User: Clicks "Add Key" -
Key saved securely
KeyRequestCard → CustomKeysService (IPC) → Electron → Keychain (encrypted) -
Agent uses key in bash command (Fix 3)
Agent: bash({ command: 'curl -H "Authorization: Bearer ${GITHUB_TOKEN}" https://api.github.com/repos/...' }) -
Bash tool loads key (Fix 3)
// Loads from BOTH: // 1. process.env (environment variables) // 2. CustomKeysStorage (Settings keys) ← FIX! const service = getCustomKeysService(); const storedKeys = await service.listKeys(); for (const keyMeta of storedKeys) { const value = await service.getKeyByName(keyMeta.name); if (value) { customKeys[keyMeta.name] = value; // ✅ GITHUB_TOKEN loaded! } }
-
Key substituted and command executes
# Before substitution: curl -H "Authorization: Bearer ${GITHUB_TOKEN}" ... # After substitution (Fix 3): curl -H "Authorization: Bearer ghp_abc123..." ... # Output: ✅ 200 OK - Repository data fetched successfully
-
Output sanitized
# Raw output might include key value # Sanitized output replaces with: curl -H "Authorization: Bearer ***" ...
Fallback (Fix 2):
If request_key fails or user prefers Settings UI:
Agent: "I'll need a GitHub token. Please:
1. Go to Settings → API Keys → Custom API Keys
2. Click 'Add Custom Key'
3. Name: GITHUB_TOKEN
4. Value: Get from github.com/settings/tokens (needs repo scope)
5. Permission: 'Always Allow' (for automation)
Let me know once you've added it and I'll continue!"
NEVER ask users to paste keys in chat - keys must go through request_key tool or Settings UI for encryption and security.
Added "Prefer request_key" as #2 best practice:
- Check keys first - Call
list_keysbefore assuming a key exists - Prefer request_key - Better UX than Settings redirect (inline card in chat)
- Only use keys when necessary - Don't fetch keys just to check them
- Use environment keys when available - Prefer
OPENAI_API_KEYover custom keys - Explain why you need the key - Context helps users approve
- Handle permission denials gracefully - Offer alternatives if possible
Before:
Agent: "I need a GitHub token. Please paste it here."
User: ghp_1234567890... [exposes key in chat]
After (with request_key tool):
Agent: [calls list_keys → no GITHUB_TOKEN]
Agent: [calls request_key({
name: "GITHUB_TOKEN",
description: "GitHub API access for fetching repository data",
sourceUrl: "github.com/settings/tokens",
requiredScopes: ["repo", "read:user"],
permission: "always"
})]
→ User sees inline KeyRequestCard in chat:
🔑 API Key Required
GitHub API access for fetching repository data
Get key from: github.com/settings/tokens ↗
Required permissions: [repo] [read:user]
GITHUB_TOKEN
[•••••••••••••••••••] 👁️
○ Always allow (for automation)
● Ask each time (more secure)
[Cancel] [Add Key]
→ User clicks source URL link (opens in browser)
→ User copies token from GitHub
→ User pastes into password field
→ User selects permission
→ User clicks "Add Key"
→ ✓ Key encrypted and saved to Keychain
Agent: "Key added successfully. Continuing with GitHub API call..."
Agent: [calls bash({ command: 'curl -H "Authorization: Bearer ${GITHUB_TOKEN}" ...' })]
→ Bash tool loads key from CustomKeysStorage (Fix 3)
→ Substitutes ${GITHUB_TOKEN} with actual value
→ Command executes successfully
→ Output sanitized (key value replaced with ***)
✅ 200 OK - Repository data fetched
Key already exists, bash tool loads it (Fix 3):
Agent: [calls list_keys]
Result: ✅ GITHUB_TOKEN exists (permission: always)
Agent: [calls bash({ command: 'curl -H "Authorization: Bearer ${GITHUB_TOKEN}" ...' })]
→ Bash tool loads keys from:
1. process.env (environment variables)
2. CustomKeysStorage (Settings keys) ← Fix 3!
→ Finds GITHUB_TOKEN in CustomKeysStorage
→ Substitutes ${GITHUB_TOKEN} with actual value
→ Command executes successfully
✅ 200 OK - Repository data fetched
When to use: request_key fails, timeout, or user prefers Settings
Agent: [calls list_keys → no GITHUB_TOKEN]
Agent: "I'll need a GitHub token. Please:
1. Go to Settings → API Keys → Custom API Keys
2. Click 'Add Custom Key'
3. Name: GITHUB_TOKEN
4. Value: Get from github.com/settings/tokens (needs repo scope)
5. Permission: 'Always Allow' (for automation)
Let me know once you've added it and I'll continue!"
User: "Done!"
Agent: [calls bash({ command: 'curl -H "Authorization: Bearer ${GITHUB_TOKEN}" ...' })]
→ Bash tool loads from CustomKeysStorage (Fix 3)
✅ Command executes successfully
What happened before the bash tool fix:
User: [Adds GITHUB_TOKEN in Settings UI]
Agent: [calls bash({ command: 'curl -H "Authorization: Bearer ${GITHUB_TOKEN}" ...' })]
❌ Error: "Token not found in environment"
❌ Agent: "The token isn't in the shell environment — it's in Paprwork's secret store..."
→ Bash tool ONLY checked process.env
→ DIDN'T check CustomKeysStorage
→ ${GITHUB_TOKEN} NOT substituted
→ Command failed with 401 (literal string "${GITHUB_TOKEN}" sent to API)
After Fix 3:
User: [Adds GITHUB_TOKEN in Settings UI]
Agent: [calls bash({ command: 'curl -H "Authorization: Bearer ${GITHUB_TOKEN}" ...' })]
✅ Bash tool checks BOTH:
1. process.env
2. CustomKeysStorage ← FIX!
✅ ${GITHUB_TOKEN} found in CustomKeysStorage
✅ Substituted with actual value
✅ Command executes successfully
✅ 200 OK - Repository data fetched
- ✅ Keys encrypted in macOS Keychain (Fix 1 + 2)
- ✅ Keys never exposed in chat history (Fix 1)
- ✅ Keys sanitized in tool outputs (shown as
***) (Fix 3) - ✅ Permission prompts for key usage
- ✅ Keys work across all sessions (bash tool loads from Settings)
- ✅ Keys work across all jobs (bash tool loads from Settings)
- ✅ Keys work across all agents/sub-agents
- ✅ User controls permission level (always/ask)
- ✅ User can revoke keys anytime
- ✅ User sees what keys are being used
- ✅ Inline key input (never leaves chat) (Fix 1)
- ✅ Fallback to Settings UI (Fix 2)
- ✅ Agent checks
list_keysbefore assuming - ✅ Clear workflow prevents confusion
- ✅ Consistent behavior across all scenarios
- ✅ Bash tool actually works with custom keys! (Fix 3)
src/core/tools/keyManagement.ts - NEW
- Added
request_keytool - Added
list_keys,get_key,set_key,delete_keytools
ui/components/Chat/KeyRequestCard.tsx - NEW
- Inline card component for secure key entry
- Password field with show/hide toggle
- Permission controls (always/ask)
- Source URL link
ui/components/Chat/KeyRequestCard.css - NEW
- Liquid Glass design system styling
src/core/agents/SystemPrompt.ts
Added new section "When You Need a Key That Doesn't Exist" with:
- Primary method:
request_keytool (inline card) - Fallback method: Settings UI instructions
- Complete workflow (check → request → instruct → wait)
- Example response template
- Security reminder
- Updated best practices
Lines added: ~50 lines
Location: After "Permission System" section, before "Best Practices"
src/core/tools/bash.ts - MODIFIED
Changes in executeBashCommand() function (lines ~79-115):
- BEFORE: Only checked
process.envfor keys - AFTER: Loads keys from BOTH
process.envANDCustomKeysStorage - Imports
getCustomKeysService()dynamically - Calls
service.listKeys()andservice.getKeyByName()for each key - Merges custom keys into
customKeysmap - Adds custom key values to
apiKeysarray for sanitization
Changes in executeBashCommandStreaming() function (lines ~262-300):
- Same fix as above for streaming version
- Ensures both streaming and non-streaming execute consistently
Impact:
- ✅
${GITHUB_TOKEN}now substitutes correctly - ✅ Custom keys from Settings now work in bash commands
- ✅ Jobs can use custom keys (not just env vars)
- ✅ Graceful fallback if CustomKeysStorage fails to load
User: "Check my GitHub issues"
Agent: [calls list_keys] → no GITHUB_TOKEN
Agent: [calls request_key({
name: "GITHUB_TOKEN",
description: "GitHub API access for issues",
sourceUrl: "github.com/settings/tokens",
requiredScopes: ["repo"],
permission: "always"
})]
→ User sees inline KeyRequestCard
→ User enters key, clicks "Add Key"
→ Key saved to Keychain
Agent: [calls bash with ${GITHUB_TOKEN}]
✅ Token substituted, command succeedsUser: "Check my GitHub issues"
Agent: [calls list_keys] → GITHUB_TOKEN exists
Agent: [calls bash: curl -H "Authorization: Bearer ${GITHUB_TOKEN}" ...]Agent: [tries to use GITHUB_TOKEN]
System: Permission denied
Agent: "The GitHub token permission was denied. Please:
1. Go to Settings → API Keys
2. Find GITHUB_TOKEN
3. Change permission to 'Always Allow'
..."- API Keys System:
docs/CUSTOM_KEYS.md - Settings UI:
ui/components/Settings/ApiKeysSettings.tsx - Key Resolution:
src/gateway/utils/keyResolver.ts - Tool Context:
src/core/tools/context.ts
- Auto-detect missing keys: Tool could return specific error when key missing
- Deep link to Settings: Open Settings UI directly to API Keys section
- Key templates: Pre-filled forms for common services (GitHub, Stripe, etc.)
- Permission hints: Suggest "Always Allow" for automation jobs vs "Ask" for manual tasks
Summary: Agent now has a clear, secure workflow for handling missing API keys. It directs users to Settings UI instead of asking them to paste keys in chat, ensuring encryption, reusability, and user control.