Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

## [Unreleased]

### 安全

- 桌面安装包与配置模板不再允许嵌入云厂商 API Key(含 DeepSeek)。首次运行不会预填真实密钥:优先本机 Ollama,云调用在密钥为空时直接拒绝并提示用户自行填写(即使环境里有 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `LLM_API_KEY` 也不写入 `providers`)。打包排除 `.env`、`octop.db`、`.freeos`,打 zip 前扫描 staging。生产/air-gap sidecar 不再回退 `LLM_API_KEY`。已发布的 **0.0.1–0.0.4** 安装包须下架(已从 Release 删除),并轮换可能泄露的 DeepSeek 密钥。

### 文档

- 增加阿拉伯语(`README.ar.md`)与葡萄牙语(`README.pt.md`)项目简介,并在各语言 README 的语言切换链接中列出。
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@
"PROVIDER_NOT_VISIBLE": "Provider is not visible.",
"PROVIDER_REFERENCED": "Provider is in use and cannot be removed.",
"PROVIDER_LOCAL_PROTECTED": "Local runtime providers cannot be deleted.",
"PROVIDER_API_KEY_REQUIRED": "Cloud model API key is empty. Set your own key in Settings → Models, or use a local model such as Ollama.",
"STORAGE_BACKEND_NAME_TAKEN": "Storage backend name is already in use.",
"STORAGE_BACKEND_REFERENCED": "Storage backend is in use and cannot be removed.",
"STORAGE_BACKEND_DEPS_FAILED": "Could not install optional components for this storage backend. Check network access and retry.",
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/locales/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,7 @@
"PROVIDER_NOT_VISIBLE": "提供商不可见。",
"PROVIDER_REFERENCED": "提供商正在使用中,无法删除。",
"PROVIDER_LOCAL_PROTECTED": "本地提供商无法删除。",
"PROVIDER_API_KEY_REQUIRED": "云模型 API Key 为空。请在「设置 → 大模型」中填写你自己的密钥,或改用 Ollama 等本机模型。",
"STORAGE_BACKEND_NAME_TAKEN": "存储后端名称已被占用。",
"STORAGE_BACKEND_REFERENCED": "存储后端正在使用中,无法删除。",
"STORAGE_BACKEND_DEPS_FAILED": "无法安装此后端所需的可选组件。请检查网络后重试。",
Expand Down
8 changes: 7 additions & 1 deletion desktop/portable/bundle-org-sidecar.sh
Original file line number Diff line number Diff line change
Expand Up @@ -125,16 +125,22 @@ build_openxyos() {
rsync -a --delete \
--exclude node_modules --exclude dist --exclude .git --exclude .github \
--exclude backups --exclude dist-backup-20260626 --exclude V0.5 \
--include '.env.example' --exclude '.env' --exclude '.env.*' \
--exclude '.freeos' --exclude '.octop' --exclude 'octop.db' --exclude '*.sqlite' \
"${src}/" "${work}/"
else
python3 - "$src" "$work" <<'PY'
import shutil, sys
from pathlib import Path
src, dest = Path(sys.argv[1]), Path(sys.argv[2])
skip = {"node_modules", "dist", ".git", ".github", "backups", "dist-backup-20260626", "V0.5"}
skip = {"node_modules", "dist", ".git", ".github", "backups", "dist-backup-20260626", "V0.5", ".freeos", ".octop"}
for item in src.iterdir():
if item.name in skip:
continue
if item.name == ".env" or (item.name.startswith(".env.") and item.name != ".env.example"):
continue
if item.name == "octop.db" or item.suffix in {".sqlite", ".sqlite3", ".db"}:
continue
target = dest / item.name
if item.is_dir():
shutil.copytree(item, target, ignore=shutil.ignore_patterns(*skip))
Expand Down
13 changes: 13 additions & 0 deletions desktop/portable/package.sh
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,19 @@ assemble_one() {
stamp="${stamp:-local}-$(date -u +%Y%m%d%H%M%S)"
printf '%s\n' "$stamp" > "${staging}/FREEOS_STAMP"

echo "[package] ${plat}: scanning staging for secrets / user home data" >&2
py_scan=""
if command -v python3 >/dev/null 2>&1; then
py_scan=python3
elif command -v python >/dev/null 2>&1; then
py_scan=python
fi
if [[ -z "$py_scan" ]]; then
echo "[package] need python to scan packaged secrets" >&2
exit 1
fi
"$py_scan" "${REPO_ROOT}/desktop/portable/scan_packaged_secrets.py" "$staging"

rm -f "$zip_path"
echo "[package] ${plat}: zipping → ${zip_path}"
local zip_name zip_stem
Expand Down
87 changes: 87 additions & 0 deletions desktop/portable/scan_packaged_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Refuse to ship a portable tree that contains user secrets or home data."""

from __future__ import annotations

import re
import sys
from pathlib import Path

_DEEPSEEK_LIKE = re.compile(r"\bsk-[a-fA-F0-9]{32,}\b")
_SKIP_DIR_NAMES = frozenset(
{
"node_modules",
"dist",
"backend-dist",
"packages",
"runtime",
"__pycache__",
}
)
_SECRET_FILENAMES = frozenset({".env", "octop.db"})
_SECRET_DIRNAMES = frozenset({".freeos", ".octop"})


def iter_findings(root: Path) -> list[str]:
hits: list[str] = []
if not root.is_dir():
return [f"{root}: staging directory missing"]
for path in root.rglob("*"):
parts = set(path.parts)
if parts & _SKIP_DIR_NAMES:
continue
if path.is_dir():
if path.name in _SECRET_DIRNAMES:
hits.append(f"{path}: user-home data directory must not be packaged")
continue
if path.name in _SECRET_FILENAMES or (
path.name.startswith(".env.") and path.name != ".env.example"
):
hits.append(f"{path}: secret/user-data file must not be packaged")
continue
if path.suffix.lower() in {".db", ".sqlite", ".sqlite3"}:
hits.append(f"{path}: sqlite database must not be packaged")
continue
if (
path.suffix.lower()
not in {
".env",
".example",
".json",
".txt",
".md",
".sh",
".bat",
".cmd",
".ps1",
".yml",
".yaml",
}
and path.name != "env"
):
continue
try:
text = path.read_text(encoding="utf-8", errors="replace")
except OSError:
continue
if _DEEPSEEK_LIKE.search(text):
hits.append(f"{path}: DeepSeek-like API key must not be packaged")
return hits


def main(argv: list[str]) -> int:
if len(argv) != 2:
sys.stderr.write("usage: scan_packaged_secrets.py <staging-dir>\n")
return 2
root = Path(argv[1])
hits = iter_findings(root)
if hits:
sys.stderr.write("packaged tree contains secrets / user data:\n")
for hit in hits:
sys.stderr.write(f" {hit}\n")
return 1
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
4 changes: 3 additions & 1 deletion modules/openxyos/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ SEED_ADMIN_EMAIL=admin@example.com
SEED_ADMIN_PASSWORD=replace-me-with-a-unique-admin-password
SEED_DEMO_PASSWORD=replace-me-with-a-unique-demo-password

# Optional LLM integration. Leave blank to start without a remote provider.
# Optional LLM integration for local development only. Leave blank.
# Desktop / production installers must never ship a real key; users set their
# own cloud key in Settings, or use a local model (Ollama).
LLM_API_KEY=
LLM_API_BASE=
LLM_MODEL=
Expand Down
7 changes: 7 additions & 0 deletions modules/openxyos/backend/llm-providers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
export const LLM_PROVIDERS = {
ollama: {
provider: "Ollama",
name: "Ollama (Local)",
baseUrl: "http://127.0.0.1:11434/v1",
model: "llama3.2",
local: true,
},
deepseek: {
provider: "DeepSeek",
name: "DeepSeek V4 Flash",
Expand Down
8 changes: 6 additions & 2 deletions modules/openxyos/backend/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,15 +316,19 @@ settingsRoutes.put("/ai/onboarding", requireAdmin, (req: AuthRequest, res) => {
const apiKey = String(req.body?.apiKey || "").trim();
const preset = LLM_PROVIDERS[providerId];
if (!preset) return res.status(400).json({ success: false, error: settingsError(req, "请选择受支持的大模型", "Choose a supported AI model") });
if (apiKey.length < 8 || apiKey.length > 512 || /\s/.test(apiKey)) {
const isLocal = providerId === "ollama" || Boolean("local" in preset && preset.local);
if (!isLocal && (apiKey.length < 8 || apiKey.length > 512 || /\s/.test(apiKey))) {
return res.status(400).json({ success: false, error: settingsError(req, "API Key 格式无效", "API key format is invalid") });
}
if (isLocal && apiKey && (apiKey.length > 512 || /\s/.test(apiKey))) {
return res.status(400).json({ success: false, error: settingsError(req, "API Key 格式无效", "API key format is invalid") });
}

const values = {
llm_provider: providerId,
llm_api_base: preset.baseUrl,
llm_model: preset.model,
llm_api_key: apiKey,
llm_api_key: isLocal ? (apiKey || "ollama") : apiKey,
ai_reply_enabled: "true",
};
for (const [key, value] of Object.entries(values)) {
Expand Down
2 changes: 1 addition & 1 deletion modules/openxyos/backend/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -966,7 +966,7 @@ function migrateDatabase() {

const existingConfig = dbGet("SELECT COUNT(*) as c FROM ai_config") as any;
if (existingConfig.c === 0) {
dbRun("INSERT INTO ai_config (key, value, description, tenant_id) VALUES (?, ?, ?, ?)", ["llm_api_key", process.env.LLM_API_KEY || "", "LLM API 密钥", 1]);
dbRun("INSERT INTO ai_config (key, value, description, tenant_id) VALUES (?, ?, ?, ?)", ["llm_api_key", "", "LLM API 密钥", 1]);
dbRun("INSERT INTO ai_config (key, value, description, tenant_id) VALUES (?, ?, ?, ?)", ["llm_api_base", process.env.LLM_API_BASE || "", "LLM API 地址", 1]);
dbRun("INSERT INTO ai_config (key, value, description, tenant_id) VALUES (?, ?, ?, ?)", ["llm_model", process.env.LLM_MODEL || "", "LLM 模型", 1]);
dbRun("INSERT INTO ai_config (key, value, description, tenant_id) VALUES (?, ?, ?, ?)", ["ai_reply_enabled", "true", "是否启用AI自动回复", 1]);
Expand Down
16 changes: 12 additions & 4 deletions modules/openxyos/backend/services/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,21 @@ export function sanitizeLLMInput(text: string): string {
.slice(0, 16_000);
}

function allowEnvLlmFallback(): boolean {
const environment = (process.env.NODE_ENV || "").trim().toLowerCase();
if (environment === "production" || environment === "private-production") return false;
if ((process.env.AIR_GAP_MODE || "").trim().toLowerCase() === "true") return false;
return true;
}

function getLLMConfig(): { apiKey: string; baseUrl: string; model: string } {
const tenantId = getRequestTenantId() ?? 1;
const getValue = (key: string) => dbGet("SELECT value FROM ai_config WHERE key = ? AND tenant_id = ?", [key, tenantId]) as any;
const envFallback = allowEnvLlmFallback();
return {
apiKey: getValue("llm_api_key")?.value || process.env.LLM_API_KEY || "",
baseUrl: getValue("llm_api_base")?.value || process.env.LLM_API_BASE || "",
model: getValue("llm_model")?.value || process.env.LLM_MODEL || "",
apiKey: getValue("llm_api_key")?.value || (envFallback ? process.env.LLM_API_KEY || "" : ""),
baseUrl: getValue("llm_api_base")?.value || (envFallback ? process.env.LLM_API_BASE || "" : ""),
model: getValue("llm_model")?.value || (envFallback ? process.env.LLM_MODEL || "" : ""),
};
}

Expand All @@ -50,7 +58,7 @@ function endpoint(baseUrl: string): string {
export async function callLLM(messages: AIMessage[], temperature = 0.7, maxTokens = 1024): Promise<AIResponse> {
const { apiKey, baseUrl, model } = getLLMConfig();
if (!aiIsEnabled()) return { content: "[系统] AI 功能已被管理员关闭。", tokens_used: 0, model: "disabled" };
if (!apiKey || !baseUrl || !model) return { content: "[系统] 请先在系统设置中完成模型服务配置。", tokens_used: 0, model: "unconfigured" };
if (!apiKey || !baseUrl || !model) return { content: "[系统] 尚未配置云模型 API Key。请在系统设置中填写你自己的密钥,或改用本机模型(如 Ollama)。", tokens_used: 0, model: "unconfigured" };
if (!assertModelEndpointAllowed(baseUrl)) return { content: "[系统] 当前网络策略不允许连接该模型服务。", tokens_used: 0, model: "blocked" };

const controller = new AbortController();
Expand Down
1 change: 1 addition & 0 deletions modules/openxyos/frontend/src/llm-providers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export const EXPERIENCE_MODELS = [
{ id: "ollama", provider: "Ollama", name: "Ollama (Local)", baseUrl: "http://127.0.0.1:11434/v1", model: "llama3.2", hint: "本机模型,无需云厂商密钥", hintEn: "Local models, no cloud API key", local: true },
{ id: "deepseek", provider: "DeepSeek", name: "DeepSeek V4 Flash", baseUrl: "https://api.deepseek.com", model: "deepseek-v4-flash", hint: "高性价比推理与通用对话", hintEn: "Cost-efficient reasoning and general chat" },
{ id: "qwen", provider: "阿里云百炼", name: "通义千问 Plus", baseUrl: "https://dashscope.aliyuncs.com/compatible-mode/v1", model: "qwen-plus", hint: "中文与企业场景", hintEn: "Chinese and enterprise scenarios" },
{ id: "openai", provider: "OpenAI", name: "GPT-5 mini", baseUrl: "https://api.openai.com/v1", model: "gpt-5-mini", hint: "通用智能与工具调用", hintEn: "General intelligence and tool use" },
Expand Down
Loading
Loading