feat(settings): credential store + fix Task 6 blocking review issues

- add scripts/credential_store.py (keyring/file/env-ref backends, Fernet encryption)
- email password stored via credential store, never returned in GET
- email GET returns password_set flag; PUT accepts new password or ${ENV_VAR} ref
- move integration actions to store (connectIntegration, testIntegration, disconnectIntegration)
- add tier-gating UI with locked state and upgrade prompt
- move subprocess/socket/imaplib/ssl imports to top level
This commit is contained in:
pyr0ball 2026-03-22 15:31:45 -07:00
parent bce997e596
commit f6ddaca14f
4 changed files with 348 additions and 62 deletions

View file

@ -4,28 +4,36 @@ Reads directly from /devl/job-seeker/staging.db.
Run with:
conda run -n job-seeker uvicorn dev-api:app --port 8600 --reload
"""
import sqlite3
import os
import sys
import re
import imaplib
import json
import logging
import os
import re
import socket
import sqlite3
import ssl as ssl_mod
import subprocess
import sys
import threading
from urllib.parse import urlparse
from bs4 import BeautifulSoup
from datetime import datetime
from pathlib import Path
from typing import Optional, List
from urllib.parse import urlparse
import requests
import yaml
from bs4 import BeautifulSoup
from fastapi import FastAPI, HTTPException, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional, List
import requests
# Allow importing peregrine scripts for cover letter generation
PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine")
if str(PEREGRINE_ROOT) not in sys.path:
sys.path.insert(0, str(PEREGRINE_ROOT))
from scripts.credential_store import get_credential, set_credential, delete_credential # noqa: E402
DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
app = FastAPI(title="Peregrine Dev API")
@ -1196,9 +1204,6 @@ def byok_ack(payload: ByokAckPayload):
# ── Settings: System — Services ───────────────────────────────────────────────
import subprocess
import socket
SERVICES_REGISTRY = [
{"name": "ollama", "port": 11434, "compose_service": "ollama", "note": "LLM inference", "profiles": ["cpu","single-gpu","dual-gpu"]},
{"name": "vllm", "port": 8000, "compose_service": "vllm", "note": "vLLM server", "profiles": ["single-gpu","dual-gpu"]},
@ -1261,15 +1266,25 @@ def stop_service(name: str):
# ── Settings: System — Email ──────────────────────────────────────────────────
EMAIL_PATH = Path("config/email.yaml")
EMAIL_CRED_SERVICE = "peregrine"
EMAIL_CRED_KEY = "imap_password"
# Non-secret fields stored in yaml
EMAIL_YAML_FIELDS = ("host", "port", "ssl", "username", "sent_folder", "lookback_days")
@app.get("/api/settings/system/email")
def get_email_config():
try:
if not EMAIL_PATH.exists():
return {}
with open(EMAIL_PATH) as f:
return yaml.safe_load(f) or {}
config = {}
if EMAIL_PATH.exists():
with open(EMAIL_PATH) as f:
config = yaml.safe_load(f) or {}
# Never return the password — only indicate whether it's set
password = get_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY)
config["password_set"] = bool(password)
config.pop("password", None) # strip if somehow in yaml
return config
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@ -1278,10 +1293,16 @@ def get_email_config():
def save_email_config(payload: dict):
try:
EMAIL_PATH.parent.mkdir(parents=True, exist_ok=True)
# Write with restricted permissions (contains password)
# Extract password before writing yaml
password = payload.pop("password", None) or payload.pop("password_set", None)
# Only store if it's a real new value (not the sentinel True/False)
if password and isinstance(password, str):
set_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY, password)
# Write non-secret fields to yaml (chmod 600 still, contains username)
safe_config = {k: v for k, v in payload.items() if k in EMAIL_YAML_FIELDS}
fd = os.open(str(EMAIL_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as f:
yaml.dump(dict(payload), f, allow_unicode=True, default_flow_style=False)
with os.fdopen(fd, "w") as f:
yaml.dump(safe_config, f, allow_unicode=True, default_flow_style=False)
return {"ok": True}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@ -1289,13 +1310,15 @@ def save_email_config(payload: dict):
@app.post("/api/settings/system/email/test")
def test_email(payload: dict):
import imaplib, ssl as ssl_mod
try:
# Allow test to pass in a password override, or use stored credential
password = payload.get("password") or get_credential(EMAIL_CRED_SERVICE, EMAIL_CRED_KEY)
host = payload.get("host", "")
port = int(payload.get("port", 993))
use_ssl = payload.get("ssl", True)
username = payload.get("username", "")
password = payload.get("password", "")
if not all([host, username, password]):
return {"ok": False, "error": "Missing host, username, or password"}
if use_ssl:
ctx = ssl_mod.create_default_context()
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx)

197
scripts/credential_store.py Normal file
View file

@ -0,0 +1,197 @@
"""
Credential store abstraction for Peregrine.
Backends (set via CREDENTIAL_BACKEND env var):
auto try keyring, fall back to file (default)
keyring python-keyring (OS Keychain / SecretService / libsecret)
file Fernet-encrypted JSON in config/credentials/ (key at config/.credential_key)
Env var references:
Any stored value matching ${VAR_NAME} is resolved from os.environ at read time.
Users can store "${IMAP_PASSWORD}" as the credential value; it is never treated
as the actual secret only the env var it points to is used.
"""
import os
import re
import json
import logging
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
_ENV_REF = re.compile(r'^\$\{([A-Z_][A-Z0-9_]*)\}$')
CRED_DIR = Path("config/credentials")
KEY_PATH = Path("config/.credential_key")
def _resolve_env_ref(value: str) -> Optional[str]:
"""If value is ${VAR_NAME}, return os.environ[VAR_NAME]; otherwise return None."""
m = _ENV_REF.match(value)
if m:
resolved = os.environ.get(m.group(1))
if resolved is None:
logger.warning("Credential reference %s is set but env var is not defined", value)
return resolved
return None
def _get_backend() -> str:
backend = os.environ.get("CREDENTIAL_BACKEND", "auto").lower()
if backend != "auto":
return backend
# Auto: try keyring, fall back to file
try:
import keyring
kr = keyring.get_keyring()
# Reject the null/fail keyring — it can't actually store anything
if "fail" in type(kr).__name__.lower() or "null" in type(kr).__name__.lower():
raise RuntimeError("No usable keyring backend found")
return "keyring"
except Exception:
return "file"
def _get_fernet():
"""Return a Fernet instance, auto-generating the key on first use."""
try:
from cryptography.fernet import Fernet
except ImportError:
return None
if KEY_PATH.exists():
key = KEY_PATH.read_bytes().strip()
else:
key = Fernet.generate_key()
KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(str(KEY_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(key)
logger.info("Generated new credential encryption key at %s", KEY_PATH)
return Fernet(key)
def _file_read(service: str) -> dict:
"""Read the credentials file for a service, decrypting if possible."""
cred_file = CRED_DIR / f"{service}.json"
if not cred_file.exists():
return {}
raw = cred_file.read_bytes()
fernet = _get_fernet()
if fernet:
try:
return json.loads(fernet.decrypt(raw))
except Exception:
# May be an older plaintext file — try reading as text
try:
return json.loads(raw.decode())
except Exception:
logger.error("Failed to read credentials for service %s", service)
return {}
else:
try:
return json.loads(raw.decode())
except Exception:
return {}
def _file_write(service: str, data: dict) -> None:
"""Write the credentials file for a service, encrypting if possible."""
CRED_DIR.mkdir(parents=True, exist_ok=True)
cred_file = CRED_DIR / f"{service}.json"
fernet = _get_fernet()
if fernet:
content = fernet.encrypt(json.dumps(data).encode())
fd = os.open(str(cred_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(content)
else:
logger.warning(
"cryptography package not installed — storing credentials as plaintext with chmod 600. "
"Install with: pip install cryptography"
)
content = json.dumps(data).encode()
fd = os.open(str(cred_file), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(content)
def get_credential(service: str, key: str) -> Optional[str]:
"""
Retrieve a credential. If the stored value is an env var reference (${VAR}),
resolves it from os.environ at call time.
"""
backend = _get_backend()
raw: Optional[str] = None
if backend == "keyring":
try:
import keyring
raw = keyring.get_password(service, key)
except Exception as e:
logger.error("keyring get failed for %s/%s: %s", service, key, e)
else: # file
data = _file_read(service)
raw = data.get(key)
if raw is None:
return None
# Resolve env var references transparently
resolved = _resolve_env_ref(raw)
if resolved is not None:
return resolved
if _ENV_REF.match(raw):
return None # reference defined but env var not set
return raw
def set_credential(service: str, key: str, value: str) -> None:
"""
Store a credential. Value may be a literal secret or a ${VAR_NAME} reference.
Env var references are stored as-is and resolved at get time.
"""
if not value:
return
backend = _get_backend()
if backend == "keyring":
try:
import keyring
keyring.set_password(service, key, value)
return
except Exception as e:
logger.error("keyring set failed for %s/%s: %s — falling back to file", service, key, e)
backend = "file"
# file backend
data = _file_read(service)
data[key] = value
_file_write(service, data)
def delete_credential(service: str, key: str) -> None:
"""Remove a stored credential."""
backend = _get_backend()
if backend == "keyring":
try:
import keyring
keyring.delete_password(service, key)
return
except Exception:
backend = "file"
data = _file_read(service)
data.pop(key, None)
if data:
_file_write(service, data)
else:
cred_file = CRED_DIR / f"{service}.json"
if cred_file.exists():
cred_file.unlink()

View file

@ -153,6 +153,46 @@ export const useSystemStore = defineStore('settings/system', () => {
if (data) integrations.value = data
}
async function connectIntegration(id: string, credentials: Record<string, string>) {
const { data, error } = await useApiFetch<{ok: boolean; error?: string}>(
`/api/settings/system/integrations/${id}/connect`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }
)
if (error || !data?.ok) {
return { ok: false, error: data?.error ?? 'Connection failed' }
}
await loadIntegrations()
return { ok: true }
}
async function testIntegration(id: string, credentials: Record<string, string>) {
const { data, error } = await useApiFetch<{ok: boolean; error?: string}>(
`/api/settings/system/integrations/${id}/test`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(credentials) }
)
return { ok: data?.ok ?? false, error: data?.error ?? (error ? 'Test failed' : undefined) }
}
async function disconnectIntegration(id: string) {
const { error } = await useApiFetch(
`/api/settings/system/integrations/${id}/disconnect`, { method: 'POST' }
)
if (!error) await loadIntegrations()
}
async function saveEmailWithPassword(payload: Record<string, unknown>) {
emailSaving.value = true
emailError.value = null
const { error } = await useApiFetch('/api/settings/system/email', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
emailSaving.value = false
if (error) emailError.value = 'Save failed — please try again.'
else await loadEmail() // reload to get fresh password_set status
}
async function loadFilePaths() {
const { data } = await useApiFetch<Record<string, string>>('/api/settings/system/paths')
if (data) filePaths.value = data
@ -191,8 +231,8 @@ export const useSystemStore = defineStore('settings/system', () => {
services, emailConfig, integrations, serviceErrors, emailSaving, emailError,
filePaths, deployConfig, filePathsSaving, deploySaving,
loadServices, startService, stopService,
loadEmail, saveEmail, testEmail,
loadIntegrations,
loadEmail, saveEmail, testEmail, saveEmailWithPassword,
loadIntegrations, connectIntegration, testIntegration, disconnectIntegration,
loadFilePaths, saveFilePaths,
loadDeployConfig, saveDeployConfig,
}

View file

@ -86,8 +86,12 @@
</div>
<div class="field-row">
<label>Password / App Password</label>
<input v-model="(store.emailConfig as any).password" type="password" />
<span class="field-hint">Gmail: use an App Password, not your account password.</span>
<input
v-model="emailPasswordInput"
type="password"
:placeholder="(store.emailConfig as any).password_set ? '••••••• (saved — enter new to change)' : 'Password'"
/>
<span class="field-hint">Gmail: use an App Password. Tip: type ${ENV_VAR_NAME} to use an environment variable.</span>
</div>
<div class="field-row">
<label>Sent Folder</label>
@ -98,7 +102,7 @@
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
</div>
<div class="form-actions">
<button @click="store.saveEmail()" :disabled="store.emailSaving" class="btn-primary">
<button @click="handleSaveEmail()" :disabled="store.emailSaving" class="btn-primary">
{{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
</button>
<button @click="handleTestEmail" class="btn-secondary">Test Connection</button>
@ -116,24 +120,39 @@
<div v-for="integration in store.integrations" :key="integration.id" class="integration-card">
<div class="integration-header">
<span class="integration-name">{{ integration.name }}</span>
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
{{ integration.connected ? 'Connected' : 'Disconnected' }}
</span>
</div>
<div v-if="!integration.connected" class="integration-form">
<div v-for="field in integration.fields" :key="field.key" class="field-row">
<label>{{ field.label }}</label>
<input v-model="integrationInputs[integration.id + ':' + field.key]"
:type="field.type === 'password' ? 'password' : 'text'" />
</div>
<div class="form-actions">
<button @click="connectIntegration(integration.id)" class="btn-primary">Connect</button>
<button @click="testIntegration(integration.id)" class="btn-secondary">Test</button>
<div class="integration-badges">
<span v-if="!meetsRequiredTier(integration.tier_required)" class="tier-badge">
Requires {{ integration.tier_required }}
</span>
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
{{ integration.connected ? 'Connected' : 'Disconnected' }}
</span>
</div>
</div>
<div v-else>
<button @click="disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
<!-- Locked state for insufficient tier -->
<div v-if="!meetsRequiredTier(integration.tier_required)" class="tier-locked">
<p>Upgrade to {{ integration.tier_required }} to use this integration.</p>
</div>
<!-- Normal state for sufficient tier -->
<template v-else>
<div v-if="!integration.connected" class="integration-form">
<div v-for="field in integration.fields" :key="field.key" class="field-row">
<label>{{ field.label }}</label>
<input v-model="integrationInputs[integration.id + ':' + field.key]"
:type="field.type === 'password' ? 'password' : 'text'" />
</div>
<div class="form-actions">
<button @click="handleConnect(integration.id)" class="btn-primary">Connect</button>
<button @click="handleTest(integration.id)" class="btn-secondary">Test</button>
<span v-if="integrationResults[integration.id]" :class="integrationResults[integration.id].ok ? 'test-ok' : 'test-fail'">
{{ integrationResults[integration.id].ok ? '✓ OK' : '✗ ' + integrationResults[integration.id].error }}
</span>
</div>
</div>
<div v-else>
<button @click="store.disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button>
</div>
</template>
</div>
</section>
@ -215,12 +234,13 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useSystemStore } from '../../stores/settings/system'
import { useAppConfigStore } from '../../stores/appConfig'
import { useApiFetch } from '../../composables/useApi'
const store = useSystemStore()
const config = useAppConfigStore()
const { tier } = storeToRefs(config)
const byokConfirmed = ref(false)
const dragIdx = ref<number | null>(null)
@ -233,6 +253,11 @@ const visibleBackends = computed(() =>
)
)
const tierOrder = ['free', 'paid', 'premium', 'ultra']
function meetsRequiredTier(required: string): boolean {
return tierOrder.indexOf(tier.value) >= tierOrder.indexOf(required || 'free')
}
function dragStart(idx: number) {
dragIdx.value = idx
}
@ -261,42 +286,40 @@ async function handleConfirmByok() {
}
const emailTestResult = ref<boolean | null>(null)
const emailPasswordInput = ref('')
const integrationInputs = ref<Record<string, string>>({})
const integrationResults = ref<Record<string, {ok: boolean; error?: string}>>({})
async function handleTestEmail() {
const result = await store.testEmail()
emailTestResult.value = result?.ok ?? false
}
async function connectIntegration(id: string) {
const integration = store.integrations.find(i => i.id === id)
if (!integration) return
const payload: Record<string, string> = {}
for (const field of integration.fields) {
payload[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
}
const { data } = await useApiFetch<{ok: boolean; error?: string}>(
`/api/settings/system/integrations/${id}/connect`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
)
if (data?.ok) await store.loadIntegrations()
async function handleSaveEmail() {
const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined }
await store.saveEmailWithPassword(payload)
}
async function testIntegration(id: string) {
async function handleConnect(id: string) {
const integration = store.integrations.find(i => i.id === id)
if (!integration) return
const payload: Record<string, string> = {}
const credentials: Record<string, string> = {}
for (const field of integration.fields) {
payload[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
}
await useApiFetch(`/api/settings/system/integrations/${id}/test`,
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }
)
const result = await store.connectIntegration(id, credentials)
integrationResults.value = { ...integrationResults.value, [id]: result }
}
async function disconnectIntegration(id: string) {
await useApiFetch(`/api/settings/system/integrations/${id}/disconnect`, { method: 'POST' })
await store.loadIntegrations()
async function handleTest(id: string) {
const integration = store.integrations.find(i => i.id === id)
if (!integration) return
const credentials: Record<string, string> = {}
for (const field of integration.fields) {
credentials[field.key] = integrationInputs.value[`${id}:${field.key}`] ?? ''
}
const result = await store.testIntegration(id, credentials)
integrationResults.value = { ...integrationResults.value, [id]: result }
}
onMounted(async () => {
@ -367,4 +390,7 @@ h3 { font-size: 1rem; font-weight: 600; margin-bottom: var(--space-3, 16px); col
.badge-connected { background: rgba(34,197,94,0.15); color: #22c55e; border: 1px solid rgba(34,197,94,0.3); }
.badge-disconnected { background: rgba(100,116,139,0.15); color: #94a3b8; border: 1px solid rgba(100,116,139,0.2); }
.empty-note { font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); padding: 16px 0; }
.tier-badge { font-size: 0.68rem; padding: 2px 7px; border-radius: 8px; background: rgba(245,158,11,0.15); color: #f59e0b; border: 1px solid rgba(245,158,11,0.3); margin-right: 6px; }
.tier-locked { padding: 12px 0; font-size: 0.85rem; color: var(--color-text-secondary, #94a3b8); }
.integration-badges { display: flex; align-items: center; gap: 4px; }
</style>