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: Run with:
conda run -n job-seeker uvicorn dev-api:app --port 8600 --reload conda run -n job-seeker uvicorn dev-api:app --port 8600 --reload
""" """
import sqlite3 import imaplib
import os
import sys
import re
import json import json
import logging
import os
import re
import socket
import sqlite3
import ssl as ssl_mod
import subprocess
import sys
import threading import threading
from urllib.parse import urlparse
from bs4 import BeautifulSoup
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Optional, List
from urllib.parse import urlparse
import requests
import yaml import yaml
from bs4 import BeautifulSoup
from fastapi import FastAPI, HTTPException, Response, UploadFile from fastapi import FastAPI, HTTPException, Response, UploadFile
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional, List
import requests
# Allow importing peregrine scripts for cover letter generation # Allow importing peregrine scripts for cover letter generation
PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine") PEREGRINE_ROOT = Path("/Library/Development/CircuitForge/peregrine")
if str(PEREGRINE_ROOT) not in sys.path: if str(PEREGRINE_ROOT) not in sys.path:
sys.path.insert(0, str(PEREGRINE_ROOT)) 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") DB_PATH = os.environ.get("STAGING_DB", "/devl/job-seeker/staging.db")
app = FastAPI(title="Peregrine Dev API") app = FastAPI(title="Peregrine Dev API")
@ -1196,9 +1204,6 @@ def byok_ack(payload: ByokAckPayload):
# ── Settings: System — Services ─────────────────────────────────────────────── # ── Settings: System — Services ───────────────────────────────────────────────
import subprocess
import socket
SERVICES_REGISTRY = [ SERVICES_REGISTRY = [
{"name": "ollama", "port": 11434, "compose_service": "ollama", "note": "LLM inference", "profiles": ["cpu","single-gpu","dual-gpu"]}, {"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"]}, {"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 ────────────────────────────────────────────────── # ── Settings: System — Email ──────────────────────────────────────────────────
EMAIL_PATH = Path("config/email.yaml") 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") @app.get("/api/settings/system/email")
def get_email_config(): def get_email_config():
try: try:
if not EMAIL_PATH.exists(): config = {}
return {} if EMAIL_PATH.exists():
with open(EMAIL_PATH) as f: with open(EMAIL_PATH) as f:
return yaml.safe_load(f) or {} 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: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@ -1278,10 +1293,16 @@ def get_email_config():
def save_email_config(payload: dict): def save_email_config(payload: dict):
try: try:
EMAIL_PATH.parent.mkdir(parents=True, exist_ok=True) 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) fd = os.open(str(EMAIL_PATH), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as f: with os.fdopen(fd, "w") as f:
yaml.dump(dict(payload), f, allow_unicode=True, default_flow_style=False) yaml.dump(safe_config, f, allow_unicode=True, default_flow_style=False)
return {"ok": True} return {"ok": True}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(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") @app.post("/api/settings/system/email/test")
def test_email(payload: dict): def test_email(payload: dict):
import imaplib, ssl as ssl_mod
try: 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", "") host = payload.get("host", "")
port = int(payload.get("port", 993)) port = int(payload.get("port", 993))
use_ssl = payload.get("ssl", True) use_ssl = payload.get("ssl", True)
username = payload.get("username", "") 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: if use_ssl:
ctx = ssl_mod.create_default_context() ctx = ssl_mod.create_default_context()
conn = imaplib.IMAP4_SSL(host, port, ssl_context=ctx) 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 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() { async function loadFilePaths() {
const { data } = await useApiFetch<Record<string, string>>('/api/settings/system/paths') const { data } = await useApiFetch<Record<string, string>>('/api/settings/system/paths')
if (data) filePaths.value = data if (data) filePaths.value = data
@ -191,8 +231,8 @@ export const useSystemStore = defineStore('settings/system', () => {
services, emailConfig, integrations, serviceErrors, emailSaving, emailError, services, emailConfig, integrations, serviceErrors, emailSaving, emailError,
filePaths, deployConfig, filePathsSaving, deploySaving, filePaths, deployConfig, filePathsSaving, deploySaving,
loadServices, startService, stopService, loadServices, startService, stopService,
loadEmail, saveEmail, testEmail, loadEmail, saveEmail, testEmail, saveEmailWithPassword,
loadIntegrations, loadIntegrations, connectIntegration, testIntegration, disconnectIntegration,
loadFilePaths, saveFilePaths, loadFilePaths, saveFilePaths,
loadDeployConfig, saveDeployConfig, loadDeployConfig, saveDeployConfig,
} }

View file

@ -86,8 +86,12 @@
</div> </div>
<div class="field-row"> <div class="field-row">
<label>Password / App Password</label> <label>Password / App Password</label>
<input v-model="(store.emailConfig as any).password" type="password" /> <input
<span class="field-hint">Gmail: use an App Password, not your account password.</span> 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>
<div class="field-row"> <div class="field-row">
<label>Sent Folder</label> <label>Sent Folder</label>
@ -98,7 +102,7 @@
<input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" /> <input v-model.number="(store.emailConfig as any).lookback_days" type="number" placeholder="30" />
</div> </div>
<div class="form-actions"> <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' }} {{ store.emailSaving ? 'Saving…' : 'Save Email Config' }}
</button> </button>
<button @click="handleTestEmail" class="btn-secondary">Test Connection</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 v-for="integration in store.integrations" :key="integration.id" class="integration-card">
<div class="integration-header"> <div class="integration-header">
<span class="integration-name">{{ integration.name }}</span> <span class="integration-name">{{ integration.name }}</span>
<span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']"> <div class="integration-badges">
{{ integration.connected ? 'Connected' : 'Disconnected' }} <span v-if="!meetsRequiredTier(integration.tier_required)" class="tier-badge">
</span> Requires {{ integration.tier_required }}
</div> </span>
<div v-if="!integration.connected" class="integration-form"> <span :class="['status-badge', integration.connected ? 'badge-connected' : 'badge-disconnected']">
<div v-for="field in integration.fields" :key="field.key" class="field-row"> {{ integration.connected ? 'Connected' : 'Disconnected' }}
<label>{{ field.label }}</label> </span>
<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> </div>
</div> </div>
<div v-else> <!-- Locked state for insufficient tier -->
<button @click="disconnectIntegration(integration.id)" class="btn-danger">Disconnect</button> <div v-if="!meetsRequiredTier(integration.tier_required)" class="tier-locked">
<p>Upgrade to {{ integration.tier_required }} to use this integration.</p>
</div> </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> </div>
</section> </section>
@ -215,12 +234,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { storeToRefs } from 'pinia'
import { useSystemStore } from '../../stores/settings/system' import { useSystemStore } from '../../stores/settings/system'
import { useAppConfigStore } from '../../stores/appConfig' import { useAppConfigStore } from '../../stores/appConfig'
import { useApiFetch } from '../../composables/useApi'
const store = useSystemStore() const store = useSystemStore()
const config = useAppConfigStore() const config = useAppConfigStore()
const { tier } = storeToRefs(config)
const byokConfirmed = ref(false) const byokConfirmed = ref(false)
const dragIdx = ref<number | null>(null) 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) { function dragStart(idx: number) {
dragIdx.value = idx dragIdx.value = idx
} }
@ -261,42 +286,40 @@ async function handleConfirmByok() {
} }
const emailTestResult = ref<boolean | null>(null) const emailTestResult = ref<boolean | null>(null)
const emailPasswordInput = ref('')
const integrationInputs = ref<Record<string, string>>({}) const integrationInputs = ref<Record<string, string>>({})
const integrationResults = ref<Record<string, {ok: boolean; error?: string}>>({})
async function handleTestEmail() { async function handleTestEmail() {
const result = await store.testEmail() const result = await store.testEmail()
emailTestResult.value = result?.ok ?? false emailTestResult.value = result?.ok ?? false
} }
async function connectIntegration(id: string) { async function handleSaveEmail() {
const integration = store.integrations.find(i => i.id === id) const payload = { ...store.emailConfig, password: emailPasswordInput.value || undefined }
if (!integration) return await store.saveEmailWithPassword(payload)
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 testIntegration(id: string) { async function handleConnect(id: string) {
const integration = store.integrations.find(i => i.id === id) const integration = store.integrations.find(i => i.id === id)
if (!integration) return if (!integration) return
const payload: Record<string, string> = {} const credentials: Record<string, string> = {}
for (const field of integration.fields) { 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`, const result = await store.connectIntegration(id, credentials)
{ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) } integrationResults.value = { ...integrationResults.value, [id]: result }
)
} }
async function disconnectIntegration(id: string) { async function handleTest(id: string) {
await useApiFetch(`/api/settings/system/integrations/${id}/disconnect`, { method: 'POST' }) const integration = store.integrations.find(i => i.id === id)
await store.loadIntegrations() 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 () => { 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-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); } .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; } .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> </style>