fix(settings): reconcile secret status and model drafts
This commit is contained in:
@@ -0,0 +1,19 @@
|
|||||||
|
# 模型密钥解密失败仍显示已配置
|
||||||
|
|
||||||
|
日期:2026-07-18
|
||||||
|
文档路径:document/development/2026-07-18/dev-logs/bugs/model-secret-decryption-status.md
|
||||||
|
|
||||||
|
## 修复记录
|
||||||
|
|
||||||
|
- 15:54:记录 bug 修复:模型密文无法由当前 `settings.key` 解密时,设置页仍根据非空密文显示“已配置”,掩盖实际不可用状态。
|
||||||
|
- Git 提交检查:`git fetch --all --prune` 成功;upstream `origin/main` 无新提交;本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose`、`787bc3a4 feat(platform): close AI expense value loop`、`242d68c3 feat(approval): add task workflow and waiver decisions`,另有 16 条。
|
||||||
|
- 原因:后端配置状态只判断数据库密文字段是否非空,前端本地草稿又可能覆盖服务端状态,均没有验证密文是否能用当前密钥成功解密。
|
||||||
|
- 修改:后端序列化配置时以实际解密结果计算模型、OnlyOffice JWT 与 SMTP 密码状态;解密只读取现有主密钥,不再因 GET 设置请求自动创建新 `settings.key`;前端合并本地草稿后重新应用服务端权威密钥状态;新增密钥轮换、密钥文件缺失和前端状态覆盖回归测试。
|
||||||
|
- 操作:在 `local-x-financial-linux` 容器内执行设置持久化测试、后端联合回归、前端全量回归、Ruff 和生产构建,并使用真实管理员设置页复验。
|
||||||
|
- 验证:主模型和备份模型仍显示“已配置”,当前无法解密的 Embedding 与 Reranker 准确显示“未配置”;后端联合回归 `165 passed`,前端全量回归 `821/821`,生产构建成功。
|
||||||
|
- 影响:管理员不会再被伪阳性状态误导;受影响的两个真实 API Key 仍需重新录入,或恢复与密文配套的旧密钥文件后才能恢复检索模型调用。
|
||||||
|
- 16:20:补齐已有主密钥格式与长度校验。
|
||||||
|
- Git 提交检查:`git fetch --all --prune` 成功;upstream `origin/main` 无新提交;本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose`、`787bc3a4 feat(platform): close AI expense value loop`、`242d68c3 feat(approval): add task workflow and waiver decisions`,另有 16 条。
|
||||||
|
- 修改:加密和解密路径统一要求已有 `settings.key` 解码后为 32 字节;非空但格式错误或长度错误的密钥直接拒绝,不再先生成无法由后续读取路径解密的密文;空文件仍按首次初始化语义生成合法密钥。
|
||||||
|
- 验证:新增非法 Base64、短密钥和空密钥文件回归;设置与管理模块后端联合回归 `176 passed`,Ruff 通过;`admin/admin + default` 实际读取状态为主模型/备份模型/OnlyOffice 已配置,Embedding/Reranker/SMTP 未配置。
|
||||||
|
- 影响:设置保存不会在损坏主密钥下产生新的不可恢复密文,读取设置也不会通过副作用掩盖密钥文件缺失。
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
# 设置草稿覆盖服务端模型集合
|
||||||
|
|
||||||
|
日期:2026-07-18
|
||||||
|
文档路径:document/development/2026-07-18/dev-logs/bugs/settings-model-draft-reconciliation.md
|
||||||
|
|
||||||
|
## 修复记录
|
||||||
|
|
||||||
|
- 16:03:记录 bug 修复:设置页本地会话草稿会整体覆盖服务端模型列表,保存任一设置时可能误删服务端新增槽位,或复活已删除槽位。
|
||||||
|
- Git 提交检查:沿用本轮 15:54 拉取检查;upstream `origin/main` 无新提交,本地 ahead 19 条,最新为 `07241b46 fix(docker): manage local postgres in default compose`、`787bc3a4 feat(platform): close AI expense value loop`、`242d68c3 feat(approval): add task workflow and waiver decisions`,另有 16 条。
|
||||||
|
- 原因:加载时先用 session 草稿的 `models` 整体覆盖服务端集合,后续只同步现存行的密钥状态;后端保存完整 `llmForm` 时会把缺失的自定义槽位视为删除。
|
||||||
|
- 修改:以服务端快照的模型集合为权威来源,仅按同一 slot 保留当前内存中未提交且不是掩码的 API Key 输入;服务端新增槽位自动补入,服务端删除槽位不再保留;首次异步加载也保护用户请求期间输入的密钥。
|
||||||
|
- 操作:在 `local-x-financial-linux` 容器内运行新增/删除自定义槽位、未提交输入和权威状态合并测试,并执行前端全量回归与生产构建。
|
||||||
|
- 验证:定向测试 22 项、前端全量测试 `823/823` 全部通过,Vite 生产构建成功,`git diff --check` 通过。
|
||||||
|
- 影响:设置页不会因陈旧浏览器草稿静默改变服务端模型清单,降低误删模型配置和检索能力中断风险。
|
||||||
@@ -26,7 +26,7 @@ def get_or_create_secret_key() -> bytes:
|
|||||||
if SECRET_KEY_FILE.exists():
|
if SECRET_KEY_FILE.exists():
|
||||||
encoded = SECRET_KEY_FILE.read_text(encoding="utf-8").strip()
|
encoded = SECRET_KEY_FILE.read_text(encoding="utf-8").strip()
|
||||||
if encoded:
|
if encoded:
|
||||||
return base64.urlsafe_b64decode(encoded.encode("ascii"))
|
return read_secret_key()
|
||||||
|
|
||||||
secret_key = secrets.token_bytes(KEY_BYTES)
|
secret_key = secrets.token_bytes(KEY_BYTES)
|
||||||
encoded = base64.urlsafe_b64encode(secret_key).decode("ascii")
|
encoded = base64.urlsafe_b64encode(secret_key).decode("ascii")
|
||||||
@@ -34,6 +34,23 @@ def get_or_create_secret_key() -> bytes:
|
|||||||
return secret_key
|
return secret_key
|
||||||
|
|
||||||
|
|
||||||
|
def read_secret_key() -> bytes:
|
||||||
|
"""只读取现有主密钥,解密路径不得因缺失密钥而生成新文件。"""
|
||||||
|
|
||||||
|
if not SECRET_KEY_FILE.exists():
|
||||||
|
raise ValueError("Secret key file is missing")
|
||||||
|
|
||||||
|
try:
|
||||||
|
encoded = SECRET_KEY_FILE.read_text(encoding="utf-8").strip()
|
||||||
|
secret_key = base64.urlsafe_b64decode(encoded.encode("ascii"))
|
||||||
|
except (OSError, UnicodeError, ValueError) as exc:
|
||||||
|
raise ValueError("Secret key file is invalid") from exc
|
||||||
|
|
||||||
|
if len(secret_key) != KEY_BYTES:
|
||||||
|
raise ValueError("Secret key length is invalid")
|
||||||
|
return secret_key
|
||||||
|
|
||||||
|
|
||||||
def _keystream(secret_key: bytes, nonce: bytes, length: int) -> bytes:
|
def _keystream(secret_key: bytes, nonce: bytes, length: int) -> bytes:
|
||||||
chunks: list[bytes] = []
|
chunks: list[bytes] = []
|
||||||
counter = 0
|
counter = 0
|
||||||
@@ -57,7 +74,14 @@ def encrypt_secret(value: str) -> str:
|
|||||||
secret_key = get_or_create_secret_key()
|
secret_key = get_or_create_secret_key()
|
||||||
nonce = secrets.token_bytes(NONCE_BYTES)
|
nonce = secrets.token_bytes(NONCE_BYTES)
|
||||||
plaintext = value.encode("utf-8")
|
plaintext = value.encode("utf-8")
|
||||||
ciphertext = bytes(a ^ b for a, b in zip(plaintext, _keystream(secret_key, nonce, len(plaintext)), strict=False))
|
ciphertext = bytes(
|
||||||
|
a ^ b
|
||||||
|
for a, b in zip(
|
||||||
|
plaintext,
|
||||||
|
_keystream(secret_key, nonce, len(plaintext)),
|
||||||
|
strict=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
mac = hmac.new(secret_key, b"mac:" + nonce + ciphertext, hashlib.sha256).digest()
|
mac = hmac.new(secret_key, b"mac:" + nonce + ciphertext, hashlib.sha256).digest()
|
||||||
|
|
||||||
encoded_nonce = base64.urlsafe_b64encode(nonce).decode("ascii")
|
encoded_nonce = base64.urlsafe_b64encode(nonce).decode("ascii")
|
||||||
@@ -78,7 +102,7 @@ def decrypt_secret(value: str) -> str:
|
|||||||
if version != SECRET_BOX_VERSION:
|
if version != SECRET_BOX_VERSION:
|
||||||
raise ValueError("Unsupported secret payload version")
|
raise ValueError("Unsupported secret payload version")
|
||||||
|
|
||||||
secret_key = get_or_create_secret_key()
|
secret_key = read_secret_key()
|
||||||
nonce = base64.urlsafe_b64decode(encoded_nonce.encode("ascii"))
|
nonce = base64.urlsafe_b64decode(encoded_nonce.encode("ascii"))
|
||||||
ciphertext = base64.urlsafe_b64decode(encoded_ciphertext.encode("ascii"))
|
ciphertext = base64.urlsafe_b64decode(encoded_ciphertext.encode("ascii"))
|
||||||
expected_mac = base64.urlsafe_b64decode(encoded_mac.encode("ascii"))
|
expected_mac = base64.urlsafe_b64decode(encoded_mac.encode("ascii"))
|
||||||
@@ -87,5 +111,12 @@ def decrypt_secret(value: str) -> str:
|
|||||||
if not hmac.compare_digest(actual_mac, expected_mac):
|
if not hmac.compare_digest(actual_mac, expected_mac):
|
||||||
raise ValueError("Secret payload integrity check failed")
|
raise ValueError("Secret payload integrity check failed")
|
||||||
|
|
||||||
plaintext = bytes(a ^ b for a, b in zip(ciphertext, _keystream(secret_key, nonce, len(ciphertext)), strict=False))
|
plaintext = bytes(
|
||||||
|
a ^ b
|
||||||
|
for a, b in zip(
|
||||||
|
ciphertext,
|
||||||
|
_keystream(secret_key, nonce, len(ciphertext)),
|
||||||
|
strict=False,
|
||||||
|
)
|
||||||
|
)
|
||||||
return plaintext.decode("utf-8")
|
return plaintext.decode("utf-8")
|
||||||
|
|||||||
@@ -126,7 +126,11 @@ class OnlyOfficeRuntimeConfig:
|
|||||||
jwt_secret: str
|
jwt_secret: str
|
||||||
|
|
||||||
|
|
||||||
def serialize_model_rows(model_rows: dict[str, SystemModelSetting]) -> list[dict[str, object]]:
|
def serialize_model_rows(
|
||||||
|
model_rows: dict[str, SystemModelSetting],
|
||||||
|
*,
|
||||||
|
configured_slots: set[str],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
ordered_rows = sorted(
|
ordered_rows = sorted(
|
||||||
model_rows.values(),
|
model_rows.values(),
|
||||||
key=lambda row: (int(row.priority or 0), str(row.slot or "")),
|
key=lambda row: (int(row.priority or 0), str(row.slot or "")),
|
||||||
@@ -138,7 +142,7 @@ def serialize_model_rows(model_rows: dict[str, SystemModelSetting]) -> list[dict
|
|||||||
"provider": row.provider,
|
"provider": row.provider,
|
||||||
"url": row.endpoint,
|
"url": row.endpoint,
|
||||||
"apiKey": "",
|
"apiKey": "",
|
||||||
"apiKeyConfigured": bool(row.api_key_encrypted),
|
"apiKeyConfigured": row.slot in configured_slots,
|
||||||
"modelId": row.model_name,
|
"modelId": row.model_name,
|
||||||
"type": MODEL_CAPABILITY_TO_TYPE.get(str(row.capability or "chat"), "llm"),
|
"type": MODEL_CAPABILITY_TO_TYPE.get(str(row.capability or "chat"), "llm"),
|
||||||
}
|
}
|
||||||
@@ -662,13 +666,20 @@ class SettingsService:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _decrypt_model_api_key(self, encrypted_value: str, *, slot: str) -> str:
|
def _decrypt_model_api_key(self, encrypted_value: str, *, slot: str) -> str:
|
||||||
|
return self._decrypt_saved_secret(
|
||||||
|
encrypted_value,
|
||||||
|
label=f"model API key for slot={slot}",
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decrypt_saved_secret(encrypted_value: str, *, label: str) -> str:
|
||||||
normalized_value = str(encrypted_value or "").strip()
|
normalized_value = str(encrypted_value or "").strip()
|
||||||
if not normalized_value:
|
if not normalized_value:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
return decrypt_secret(normalized_value)
|
return decrypt_secret(normalized_value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning("Skipping undecryptable model API key for slot=%s", slot)
|
logger.warning("Skipping undecryptable %s", label)
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
def _ensure_settings_schema(self) -> None:
|
def _ensure_settings_schema(self) -> None:
|
||||||
@@ -842,8 +853,8 @@ class SettingsService:
|
|||||||
else:
|
else:
|
||||||
config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * *"
|
config.cron_expression = f"{int(parts[1])} {int(parts[0])} * * *"
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _serialize(
|
def _serialize(
|
||||||
|
self,
|
||||||
settings_row: SystemSetting,
|
settings_row: SystemSetting,
|
||||||
secrets_row: SystemSettingSecret,
|
secrets_row: SystemSettingSecret,
|
||||||
model_rows: dict[str, SystemModelSetting],
|
model_rows: dict[str, SystemModelSetting],
|
||||||
@@ -853,6 +864,11 @@ class SettingsService:
|
|||||||
backup_model = model_rows["backup"]
|
backup_model = model_rows["backup"]
|
||||||
embedding_model = model_rows["embedding"]
|
embedding_model = model_rows["embedding"]
|
||||||
reranker_model = model_rows["reranker"]
|
reranker_model = model_rows["reranker"]
|
||||||
|
configured_slots = {
|
||||||
|
slot
|
||||||
|
for slot, model_row in model_rows.items()
|
||||||
|
if self._decrypt_model_api_key(model_row.api_key_encrypted, slot=slot)
|
||||||
|
}
|
||||||
|
|
||||||
return SettingsRead(
|
return SettingsRead(
|
||||||
companyForm={
|
companyForm={
|
||||||
@@ -886,29 +902,37 @@ class SettingsService:
|
|||||||
"mainModel": main_model.model_name,
|
"mainModel": main_model.model_name,
|
||||||
"mainEndpoint": main_model.endpoint,
|
"mainEndpoint": main_model.endpoint,
|
||||||
"mainApiKey": "",
|
"mainApiKey": "",
|
||||||
"mainApiKeyConfigured": bool(main_model.api_key_encrypted),
|
"mainApiKeyConfigured": "main" in configured_slots,
|
||||||
"backupProvider": backup_model.provider,
|
"backupProvider": backup_model.provider,
|
||||||
"backupModel": backup_model.model_name,
|
"backupModel": backup_model.model_name,
|
||||||
"backupEndpoint": backup_model.endpoint,
|
"backupEndpoint": backup_model.endpoint,
|
||||||
"backupApiKey": "",
|
"backupApiKey": "",
|
||||||
"backupApiKeyConfigured": bool(backup_model.api_key_encrypted),
|
"backupApiKeyConfigured": "backup" in configured_slots,
|
||||||
"embeddingProvider": embedding_model.provider,
|
"embeddingProvider": embedding_model.provider,
|
||||||
"embeddingModel": embedding_model.model_name,
|
"embeddingModel": embedding_model.model_name,
|
||||||
"embeddingEndpoint": embedding_model.endpoint,
|
"embeddingEndpoint": embedding_model.endpoint,
|
||||||
"embeddingApiKey": "",
|
"embeddingApiKey": "",
|
||||||
"embeddingApiKeyConfigured": bool(embedding_model.api_key_encrypted),
|
"embeddingApiKeyConfigured": "embedding" in configured_slots,
|
||||||
"rerankerProvider": reranker_model.provider,
|
"rerankerProvider": reranker_model.provider,
|
||||||
"rerankerModel": reranker_model.model_name,
|
"rerankerModel": reranker_model.model_name,
|
||||||
"rerankerEndpoint": reranker_model.endpoint,
|
"rerankerEndpoint": reranker_model.endpoint,
|
||||||
"rerankerApiKey": "",
|
"rerankerApiKey": "",
|
||||||
"rerankerApiKeyConfigured": bool(reranker_model.api_key_encrypted),
|
"rerankerApiKeyConfigured": "reranker" in configured_slots,
|
||||||
"models": serialize_model_rows(model_rows),
|
"models": serialize_model_rows(
|
||||||
|
model_rows,
|
||||||
|
configured_slots=configured_slots,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
renderForm={
|
renderForm={
|
||||||
"enabled": settings_row.onlyoffice_enabled,
|
"enabled": settings_row.onlyoffice_enabled,
|
||||||
"publicUrl": settings_row.onlyoffice_public_url,
|
"publicUrl": settings_row.onlyoffice_public_url,
|
||||||
"jwtSecret": "",
|
"jwtSecret": "",
|
||||||
"jwtSecretConfigured": bool(secrets_row.onlyoffice_jwt_secret_encrypted),
|
"jwtSecretConfigured": bool(
|
||||||
|
self._decrypt_saved_secret(
|
||||||
|
secrets_row.onlyoffice_jwt_secret_encrypted,
|
||||||
|
label="OnlyOffice JWT secret",
|
||||||
|
)
|
||||||
|
),
|
||||||
},
|
},
|
||||||
logForm={
|
logForm={
|
||||||
"level": settings_row.log_level,
|
"level": settings_row.log_level,
|
||||||
@@ -928,7 +952,12 @@ class SettingsService:
|
|||||||
"senderAddress": settings_row.sender_address,
|
"senderAddress": settings_row.sender_address,
|
||||||
"username": settings_row.smtp_username,
|
"username": settings_row.smtp_username,
|
||||||
"password": "",
|
"password": "",
|
||||||
"passwordConfigured": bool(secrets_row.smtp_password_encrypted),
|
"passwordConfigured": bool(
|
||||||
|
self._decrypt_saved_secret(
|
||||||
|
secrets_row.smtp_password_encrypted,
|
||||||
|
label="SMTP password",
|
||||||
|
)
|
||||||
|
),
|
||||||
"alertEnabled": settings_row.alert_enabled,
|
"alertEnabled": settings_row.alert_enabled,
|
||||||
"digestEnabled": settings_row.digest_enabled,
|
"digestEnabled": settings_row.digest_enabled,
|
||||||
"digestTime": settings_row.digest_time,
|
"digestTime": settings_row.digest_time,
|
||||||
|
|||||||
40
server/tests/test_secret_box.py
Normal file
40
server/tests/test_secret_box.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core import secret_box
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"encoded_key",
|
||||||
|
[
|
||||||
|
"not-valid-base64!",
|
||||||
|
base64.urlsafe_b64encode(b"short-key").decode("ascii"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_encrypt_rejects_invalid_existing_secret_key(
|
||||||
|
monkeypatch,
|
||||||
|
tmp_path,
|
||||||
|
encoded_key: str,
|
||||||
|
) -> None:
|
||||||
|
key_file = tmp_path / "settings.key"
|
||||||
|
key_file.write_text(encoded_key, encoding="utf-8")
|
||||||
|
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", key_file)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
secret_box.encrypt_secret("probe")
|
||||||
|
|
||||||
|
assert key_file.read_text(encoding="utf-8") == encoded_key
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypt_replaces_empty_key_file_with_valid_key(monkeypatch, tmp_path) -> None:
|
||||||
|
key_file = tmp_path / "settings.key"
|
||||||
|
key_file.write_text("", encoding="utf-8")
|
||||||
|
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", key_file)
|
||||||
|
|
||||||
|
encrypted = secret_box.encrypt_secret("probe")
|
||||||
|
|
||||||
|
assert secret_box.decrypt_secret(encrypted) == "probe"
|
||||||
|
assert len(secret_box.read_secret_key()) == secret_box.KEY_BYTES
|
||||||
@@ -123,6 +123,60 @@ def test_blank_secret_input_does_not_clear_saved_secret(monkeypatch) -> None:
|
|||||||
assert service.load_saved_model_api_key("main") == "persisted-key"
|
assert service.load_saved_model_api_key("main") == "persisted-key"
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_marks_undecryptable_model_keys_as_unconfigured(monkeypatch) -> None:
|
||||||
|
temp_dir = build_temp_secret_dir()
|
||||||
|
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
||||||
|
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
|
||||||
|
|
||||||
|
with build_session(temp_dir / "settings.db") as db:
|
||||||
|
service = SettingsService(db)
|
||||||
|
payload = service.get_settings_snapshot().model_dump()
|
||||||
|
payload["llmForm"]["mainApiKey"] = "main-secret"
|
||||||
|
payload["llmForm"]["embeddingApiKey"] = "embedding-secret"
|
||||||
|
payload["llmForm"]["rerankerApiKey"] = "reranker-secret"
|
||||||
|
service.save_settings_snapshot(SettingsWrite(**payload))
|
||||||
|
|
||||||
|
secret_box.SECRET_KEY_FILE.unlink()
|
||||||
|
secret_box.get_or_create_secret_key()
|
||||||
|
|
||||||
|
snapshot = service.get_settings_snapshot()
|
||||||
|
configured_by_slot = {
|
||||||
|
model.slot: model.apiKeyConfigured for model in snapshot.llmForm.models
|
||||||
|
}
|
||||||
|
|
||||||
|
assert snapshot.llmForm.mainApiKeyConfigured is False
|
||||||
|
assert snapshot.llmForm.embeddingApiKeyConfigured is False
|
||||||
|
assert snapshot.llmForm.rerankerApiKeyConfigured is False
|
||||||
|
assert configured_by_slot["main"] is False
|
||||||
|
assert configured_by_slot["embedding"] is False
|
||||||
|
assert configured_by_slot["reranker"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_does_not_create_missing_secret_key_and_marks_secrets_unconfigured(monkeypatch) -> None:
|
||||||
|
temp_dir = build_temp_secret_dir()
|
||||||
|
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
||||||
|
monkeypatch.setattr(Base.metadata, "create_all", lambda *args, **kwargs: None)
|
||||||
|
monkeypatch.setenv("HERMES_HOME", str(temp_dir / ".hermes"))
|
||||||
|
|
||||||
|
with build_session(temp_dir / "settings.db") as db:
|
||||||
|
service = SettingsService(db)
|
||||||
|
payload = service.get_settings_snapshot().model_dump()
|
||||||
|
payload["llmForm"]["mainApiKey"] = "main-secret"
|
||||||
|
payload["renderForm"]["jwtSecret"] = "onlyoffice-secret"
|
||||||
|
payload["mailForm"]["password"] = "smtp-secret"
|
||||||
|
service.save_settings_snapshot(SettingsWrite(**payload))
|
||||||
|
|
||||||
|
secret_box.SECRET_KEY_FILE.unlink()
|
||||||
|
|
||||||
|
snapshot = service.get_settings_snapshot()
|
||||||
|
|
||||||
|
assert secret_box.SECRET_KEY_FILE.exists() is False
|
||||||
|
assert snapshot.llmForm.mainApiKeyConfigured is False
|
||||||
|
assert snapshot.renderForm.jwtSecretConfigured is False
|
||||||
|
assert snapshot.mailForm.passwordConfigured is False
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_model_config_returns_decrypted_main_model(monkeypatch) -> None:
|
def test_runtime_model_config_returns_decrypted_main_model(monkeypatch) -> None:
|
||||||
temp_dir = build_temp_secret_dir()
|
temp_dir = build_temp_secret_dir()
|
||||||
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
monkeypatch.setattr(secret_box, "SECRET_KEY_FILE", temp_dir / "settings.key")
|
||||||
|
|||||||
@@ -13,11 +13,13 @@ import {
|
|||||||
PROVIDER_OPTIONS,
|
PROVIDER_OPTIONS,
|
||||||
SECTION_DEFINITIONS,
|
SECTION_DEFINITIONS,
|
||||||
SESSION_RETENTION_OPTIONS,
|
SESSION_RETENTION_OPTIONS,
|
||||||
|
applyAuthoritativeSecretStatus,
|
||||||
buildDefaultState,
|
buildDefaultState,
|
||||||
buildLlmPayload,
|
buildLlmPayload,
|
||||||
buildRenderPayload,
|
buildRenderPayload,
|
||||||
computeSectionStatus,
|
computeSectionStatus,
|
||||||
isModelConfigReady,
|
isModelConfigReady,
|
||||||
|
isModelSecretMask,
|
||||||
isRenderSecretMask,
|
isRenderSecretMask,
|
||||||
maskConfiguredModelSecrets,
|
maskConfiguredModelSecrets,
|
||||||
maskConfiguredRenderSecret,
|
maskConfiguredRenderSecret,
|
||||||
@@ -106,13 +108,19 @@ export function useSettings() {
|
|||||||
nextState = mergeState(nextState, readStoredSettings())
|
nextState = mergeState(nextState, readStoredSettings())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nextState = applyAuthoritativeSecretStatus(nextState, snapshot)
|
||||||
|
|
||||||
if (preserveModelApiKeys) {
|
if (preserveModelApiKeys) {
|
||||||
nextState.llmForm.mainApiKey = currentState.llmForm.mainApiKey
|
const preserveApiKeyInput = (value) => isModelSecretMask(value) ? '' : value
|
||||||
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
|
nextState.llmForm.mainApiKey = preserveApiKeyInput(currentState.llmForm.mainApiKey)
|
||||||
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
|
nextState.llmForm.backupApiKey = preserveApiKeyInput(currentState.llmForm.backupApiKey)
|
||||||
nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey
|
nextState.llmForm.embeddingApiKey = preserveApiKeyInput(currentState.llmForm.embeddingApiKey)
|
||||||
|
nextState.llmForm.rerankerApiKey = preserveApiKeyInput(currentState.llmForm.rerankerApiKey)
|
||||||
const modelApiKeysBySlot = new Map(
|
const modelApiKeysBySlot = new Map(
|
||||||
normalizeLlmModelRows(currentState.llmForm.models).map((row) => [row.slot, row.apiKey])
|
normalizeLlmModelRows(currentState.llmForm.models).map((row) => [
|
||||||
|
row.slot,
|
||||||
|
preserveApiKeyInput(row.apiKey)
|
||||||
|
])
|
||||||
)
|
)
|
||||||
nextState.llmForm.models = normalizeLlmModelRows(nextState.llmForm.models).map((row) => ({
|
nextState.llmForm.models = normalizeLlmModelRows(nextState.llmForm.models).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -148,7 +156,10 @@ export function useSettings() {
|
|||||||
async function loadSettingsSnapshot() {
|
async function loadSettingsSnapshot() {
|
||||||
try {
|
try {
|
||||||
const snapshot = await fetchSettings()
|
const snapshot = await fetchSettings()
|
||||||
applyLoadedSnapshot(snapshot, { mergeDraft: true })
|
applyLoadedSnapshot(snapshot, {
|
||||||
|
mergeDraft: true,
|
||||||
|
preserveModelApiKeys: true
|
||||||
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
persistSettings(pageState.value)
|
persistSettings(pageState.value)
|
||||||
updateBrandPreviewFromState(pageState.value)
|
updateBrandPreviewFromState(pageState.value)
|
||||||
|
|||||||
@@ -483,6 +483,63 @@ export function mergeState(baseState, overrideState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyAuthoritativeSecretStatus(state, snapshot) {
|
||||||
|
const stateModelRows = normalizeLlmModelRows(state.llmForm?.models)
|
||||||
|
const localApiKeysBySlot = new Map(
|
||||||
|
stateModelRows.map((row) => [
|
||||||
|
row.slot,
|
||||||
|
isModelSecretMask(row.apiKey) ? '' : row.apiKey
|
||||||
|
])
|
||||||
|
)
|
||||||
|
const nextState = {
|
||||||
|
...state,
|
||||||
|
adminForm: { ...(state.adminForm || {}) },
|
||||||
|
llmForm: {
|
||||||
|
...(state.llmForm || {}),
|
||||||
|
models: stateModelRows
|
||||||
|
},
|
||||||
|
renderForm: { ...(state.renderForm || {}) },
|
||||||
|
mailForm: { ...(state.mailForm || {}) }
|
||||||
|
}
|
||||||
|
const snapshotLlmForm = snapshot?.llmForm || {}
|
||||||
|
const configuredKeyNames = [
|
||||||
|
'mainApiKeyConfigured',
|
||||||
|
'backupApiKeyConfigured',
|
||||||
|
'embeddingApiKeyConfigured',
|
||||||
|
'rerankerApiKeyConfigured'
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const key of configuredKeyNames) {
|
||||||
|
if (Object.hasOwn(snapshotLlmForm, key)) {
|
||||||
|
nextState.llmForm[key] = Boolean(snapshotLlmForm[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(snapshotLlmForm.models)) {
|
||||||
|
const authoritativeModels = normalizeLlmModelRows(snapshotLlmForm.models).map((row) => ({
|
||||||
|
...row,
|
||||||
|
apiKey: localApiKeysBySlot.get(row.slot) || row.apiKey,
|
||||||
|
apiKeyConfigured: Boolean(row.apiKeyConfigured)
|
||||||
|
}))
|
||||||
|
nextState.llmForm = syncLegacyModelFieldsFromRows({
|
||||||
|
...nextState.llmForm,
|
||||||
|
models: authoritativeModels
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [formKey, statusKey] of [
|
||||||
|
['adminForm', 'adminPasswordConfigured'],
|
||||||
|
['renderForm', 'jwtSecretConfigured'],
|
||||||
|
['mailForm', 'passwordConfigured']
|
||||||
|
]) {
|
||||||
|
if (Object.hasOwn(snapshot?.[formKey] || {}, statusKey)) {
|
||||||
|
nextState[formKey][statusKey] = Boolean(snapshot[formKey][statusKey])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nextState
|
||||||
|
}
|
||||||
|
|
||||||
export function sanitizeForStorage(state) {
|
export function sanitizeForStorage(state) {
|
||||||
return {
|
return {
|
||||||
companyForm: { ...state.companyForm },
|
companyForm: { ...state.companyForm },
|
||||||
|
|||||||
99
web/tests/settings-secret-status.test.mjs
Normal file
99
web/tests/settings-secret-status.test.mjs
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import test from 'node:test'
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyAuthoritativeSecretStatus
|
||||||
|
} from '../src/utils/settingsModelHelper.js'
|
||||||
|
|
||||||
|
test('server secret status overrides stale configured flags from the session draft', () => {
|
||||||
|
const modelRows = [
|
||||||
|
{
|
||||||
|
slot: 'main',
|
||||||
|
provider: 'Ali',
|
||||||
|
url: 'https://example.com',
|
||||||
|
apiKey: 'unsaved-main-key',
|
||||||
|
modelId: 'main',
|
||||||
|
type: 'llm'
|
||||||
|
},
|
||||||
|
{ slot: 'backup', provider: 'GLM', url: 'https://example.com', modelId: 'backup', type: 'llm' },
|
||||||
|
{ slot: 'embedding', provider: 'GLM', url: 'https://example.com', modelId: 'embedding', type: 'embedding' },
|
||||||
|
{ slot: 'reranker', provider: 'Ali', url: 'https://example.com', modelId: 'reranker', type: 'rerank' }
|
||||||
|
]
|
||||||
|
const staleDraft = {
|
||||||
|
companyForm: {},
|
||||||
|
appearanceForm: {},
|
||||||
|
adminForm: { adminPasswordConfigured: true },
|
||||||
|
sessionForm: {},
|
||||||
|
hermesForm: {},
|
||||||
|
llmForm: {
|
||||||
|
embeddingApiKeyConfigured: true,
|
||||||
|
rerankerApiKeyConfigured: true,
|
||||||
|
models: [
|
||||||
|
...modelRows.map((model) => ({
|
||||||
|
...model,
|
||||||
|
apiKeyConfigured: true
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
slot: 'server_deleted',
|
||||||
|
provider: 'Ali',
|
||||||
|
url: 'https://stale.example.com',
|
||||||
|
modelId: 'stale',
|
||||||
|
type: 'llm',
|
||||||
|
apiKeyConfigured: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
renderForm: { jwtSecretConfigured: true },
|
||||||
|
logForm: {},
|
||||||
|
mailForm: { passwordConfigured: true }
|
||||||
|
}
|
||||||
|
const serverSnapshot = {
|
||||||
|
adminForm: { adminPasswordConfigured: true },
|
||||||
|
llmForm: {
|
||||||
|
mainApiKeyConfigured: true,
|
||||||
|
backupApiKeyConfigured: true,
|
||||||
|
embeddingApiKeyConfigured: false,
|
||||||
|
rerankerApiKeyConfigured: false,
|
||||||
|
models: [
|
||||||
|
...modelRows.map((model) => ({
|
||||||
|
...model,
|
||||||
|
apiKey: '',
|
||||||
|
apiKeyConfigured: ['main', 'backup'].includes(model.slot)
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
slot: 'server_only',
|
||||||
|
provider: 'GLM',
|
||||||
|
url: 'https://server.example.com',
|
||||||
|
apiKey: '',
|
||||||
|
modelId: 'server-model',
|
||||||
|
type: 'llm',
|
||||||
|
apiKeyConfigured: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
renderForm: { jwtSecretConfigured: false },
|
||||||
|
mailForm: { passwordConfigured: false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolved = applyAuthoritativeSecretStatus(staleDraft, serverSnapshot)
|
||||||
|
const configuredBySlot = new Map(
|
||||||
|
resolved.llmForm.models.map((model) => [model.slot, model.apiKeyConfigured])
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(resolved.llmForm.mainApiKeyConfigured, true)
|
||||||
|
assert.equal(resolved.llmForm.embeddingApiKeyConfigured, false)
|
||||||
|
assert.equal(resolved.llmForm.rerankerApiKeyConfigured, false)
|
||||||
|
assert.equal(configuredBySlot.get('main'), true)
|
||||||
|
assert.equal(configuredBySlot.get('embedding'), false)
|
||||||
|
assert.equal(configuredBySlot.get('reranker'), false)
|
||||||
|
assert.equal(configuredBySlot.get('server_only'), true)
|
||||||
|
assert.equal(configuredBySlot.has('server_deleted'), false)
|
||||||
|
assert.equal(
|
||||||
|
resolved.llmForm.models.find((model) => model.slot === 'main')?.apiKey,
|
||||||
|
'unsaved-main-key'
|
||||||
|
)
|
||||||
|
assert.equal(resolved.llmForm.mainApiKey, 'unsaved-main-key')
|
||||||
|
assert.equal(resolved.adminForm.adminPasswordConfigured, true)
|
||||||
|
assert.equal(resolved.renderForm.jwtSecretConfigured, false)
|
||||||
|
assert.equal(resolved.mailForm.passwordConfigured, false)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user