feat(config): 模型与端点配置层(ADR-0011,决策 D15 / D16)

server/config.ts 按 ADR-0011 读取配置:环境变量 > ~/.aps/config.json > 默认值;默认 DeepSeek(https://api.deepseek.com/v1,deepseek-chat);密钥只在配置对象里出现一次,日志与启动横幅一律走 redactConfig。.env.example 改为平台自己的变量并写清优先级。serve 启动打印脱敏配置与「模型是否已配置」。数据库默认 ~/.aps/aps.sqlite(决策 D16,驱动用 Node 内置 node:sqlite,实测 22.19 免 flag、零原生依赖)。顺带收紧跨库访问检查的边界:平台自己的 APS_DB_PATH 不算越界(补一条用例锁住),避免误报。测试 47 → 54 全绿。
This commit is contained in:
zh 2026-09-28 16:01:04 +08:00
parent cd883d9ad7
commit 0e1a1bb5c0
11 changed files with 285 additions and 90 deletions

View File

@ -1,86 +1,31 @@
# ============================================================
# APS 智能体 · 环境配置模板(复制为 .env 后填写,.env 已被 gitignore)
# 密钥只存环境,绝不入库(plan.md §14.4)
# 多系统智能体平台 · 环境变量模板
# 复制为 .env 或写进 ~/.aps/config.json;.env 已被 gitignore
# 优先级:环境变量 > 配置文件 > 默认值(ADR-0011)
# 密钥只进配置文件或环境变量,绝不入库、不进日志
# ============================================================
# ---- 大模型 Provider(OpenAI 兼容;配置后开放问答/意图语义更强)----
# 可选:deepseek | kimi | moonshot | doubao | openai | compatible
# 留空或密钥缺失时:快路 + 项目盘点离线作答(仍可分析数据,不甩「听不懂」)
LLM_PROVIDER=
# API 密钥
LLM_API_KEY=
# 接口地址(可覆盖默认):
# deepseek → https://api.deepseek.com/v1
# kimi → https://api.moonshot.cn/v1
# doubao → https://ark.cn-beijing.volces.com/api/v3 (模型填接入点 ID)
# openai → https://api.openai.com/v1
# 其它兼容网关 → 填完整 /v1 根路径,LLM_PROVIDER=compatible
LLM_BASE_URL=
# 模型名:deepseek-chat / moonshot-v1-8k / gpt-4o-mini / 豆包 endpoint id 等
LLM_MODEL=
# ---- 模型与端点(ADR-0011;开发与首版用 DeepSeek)----
# deepseek | openai | compatible
APS_LLM_PROVIDER=deepseek
APS_LLM_BASE_URL=https://api.deepseek.com/v1
APS_LLM_MODEL=deepseek-chat
# 也写在 ~/.aps/config.json 的 llm.apiKey 里;这里留空表示未配置模型(平台仍可启动,走假引擎)
APS_LLM_API_KEY=
# ---- 运行形态(桌面 ~/.aps,Web 用仓库 data)----
# desktop | web(默认 web)
APS_MODE=web
# 显式主目录(覆盖默认:desktop→%USERPROFILE%\.aps,web→server/data)
# APS_HOME=
# Skill 单文件兼容(测试用);正式为 APS_HOME/skills/<id>/manifest.json
# APS_SKILLS_PATH=
# Web 端演示数据懒播种:1=租户首次打开工作区且无项目时自动创建演示项目(镜像默认开)
# APS_WEB_DEMO_SEED=1
# ---- 配置文件位置(默认 ~/.aps/config.json)----
# APS_CONFIG_FILE=
# ---- 后端服务 ----
APS_PORT=8000
APS_WORLD_PATH=server/data/world.json
# ---- 服务端 ----
APS_HOST=127.0.0.1
APS_PORT=5199
# 客户端静态产物目录(默认仓库内 client/app/dist)
# APS_CLIENT_DIST=
# ---- 用户认证与租户隔离(JMS) ----
# jms:调用真实 JMS 登录接口;unconfigured:关闭 Web 认证和业务 API
# 统一登录开关:1=启用 Web 登录/桌面授权; 0=本地免登录管理员(仅限开发/演示)
APS_AUTH_ENABLED=1
APS_AUTH_PROVIDER=jms
APS_AUTH_COOKIE_SECURE=0
APS_AUTH_TTL_SECONDS=28800
# 门禁审批队列与一次性执行凭据(file 默认兼容;database 显式启用共享 MySQL)
APS_APPROVAL_BACKEND=file
# APS_APPROVAL_PATH=
# database 后端必须显式配置 APS_DATABASE_URL,且不得同时配置 APS_APPROVAL_PATH;连接失败不回退 file
# SQLite 仅限测试:APS_APPROVAL_DATABASE_ALLOW_SQLITE=1(共享部署禁止使用)
APS_APPROVAL_TTL_SECONDS=86400
APS_EXECUTION_GRANT_TTL_SECONDS=300
APS_APPROVAL_INITIATOR_ROLES=planner,approver,admin
APS_APPROVAL_ROLES=planner,approver,admin
APS_APPROVAL_VIEW_ROLES=planner,approver,admin,auditor
JMS_AUTH_BASE_URL=
# 可选:固定为单一租户(部署时写死企业,用户仍可手填名称校验)
# 公开租户列表已取消;登录页手填企业名称直接提交
JMS_AUTH_TENANT_CODE=
JMS_AUTH_TENANT_NAME=
# 至少 32 个随机字符,只用于签名本系统的 HttpOnly 会话,不发送给 JMS
JMS_AUTH_SESSION_SECRET=
# 可选:固定 JMS 登录响应中的 tokenName;配置后会严格校验
# JMS_AUTH_TOKEN_NAME=
JMS_AUTH_TIMEOUT_SECONDS=10
# 可选:企业自己的账号申请页面;未配置时提示联系企业管理员开通
# JMS_AUTH_ACCOUNT_REQUEST_URL=https://example.com/account-request
# ---- 数据库(决策 D16:首版用 SQLite)----
# 默认 ~/.aps/aps.sqlite;服务端部署时显式指定
# APS_DB_PATH=
# ---- 桌面客户端授权 ----
# Web 用户认证与桌面授权相互独立。正式环境接入授权中心前保持 unconfigured(默认拒绝)。
# mock 仅用于本地桌面开发,内置六类可跨开发设备复用的 APS-*-DEMO 授权码。
APS_LICENSE_PROVIDER=unconfigured
# APS_MOCK_LICENSE_CODES={"MY-HOUR-CODE":"hour","MY-PERM-CODE":"permanent"}
# mock 模式必须显式配置随机签名密钥和租户标识;npm run dev:desktop 会为本地开发自动生成。
# APS_MOCK_LICENSE_SECRET=
# APS_MOCK_LICENSE_TENANT_UUID=
# ---- 现场数据目录(可选;未配置时不绑定任何机器目录)----
# KANGNI_DATA_DIR=
# KANGNI_ROUTE_XLSX=
# RUIYANG_DEMO_DIR=
# ---- 数据库 ----
# 未配置时继续使用 APS_DB_PATH 指定的 SQLite;接入 jms_mes 后填写 MySQL URL
# APS_DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/jms_mes?charset=utf8mb4
APS_DB_PATH=server/data/master.db
# 生产环境填写实际前端来源,逗号分隔
APS_CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
# ---- 开发期开关 ----
# 1=注册假 Provider(用于联调与演示;默认不注册,能力清单为空)
# APS_DEV_PROVIDER=1

View File

@ -0,0 +1,42 @@
# ADR-0011:模型与端点(DeepSeek + 本机配置文件)
- **状态**:已接受
- **日期**:2026-09-28
- **决策人**:技术负责人
- **相关**:任务表 A0(决策 D15);ADR-0006(引擎运行形态);ADR-0009(交付形态)
## 背景
引擎要真跑起来就必须调模型。这件事有两个必须现在定的点:用哪个端点,以及密钥放在哪。密钥的处置还牵动一条硬规则——凭据不进模型上下文、不进日志、不进仓库(架构设计 5.1)。
## 选项
| 选项 | 做法 | 代价 | 不选的理由 |
| --- | --- | --- | --- |
| A DeepSeek + 本机配置文件 | 端点用 DeepSeek 的 OpenAI 兼容接口;密钥写在本机配置文件里 | 依赖外部服务可用性 | 选定 |
| B 内网自建网关 | 部署兼容网关,统一收口 | 需要运维与卡资源 | 首版没有现成网关;接口按 OpenAI 兼容写,将来换端点只改配置 |
| C 密钥进环境变量 | 全部走 env | CI 里方便 | 本地开发与桌面端每次都要设环境变量;env 还会被子进程继承,容易外泄 |
## 结论
选 A,并定下配置的三条规则:
| 项 | 结论 |
| --- | --- |
| 端点 | `https://api.deepseek.com/v1`(OpenAI 兼容),模型 `deepseek-chat` |
| 密钥位置 | 本机配置文件 `~/.aps/config.json` 的 `llm.apiKey`;也可用 `APS_CONFIG_FILE` 指定其它路径 |
| 优先级 | 环境变量 > 配置文件 > 默认值(方便 CI 与临时覆盖) |
| 泄露防线 | 密钥不进仓库、不进日志、不进 API 响应;配置对象提供脱敏视图供日志使用 |
| 不可用时 | 模型未配置不阻塞平台启动:默认走假引擎,需要真引擎时报明确错误(A3.3 落地) |
## 影响
- **对代码**:新增 `server/config.ts`;引擎适配层从它取端点与密钥,不自己读环境
- **对任务**:A3.3 子进程接入时把配置注入 Pi 子进程的启动环境;A10.8 发版清单要核对密钥不随包分发
- **对文档**:`.env.example` 改为平台自己的变量;术语表无需改
- **不这么做的后果**:密钥散在代码与 shell 历史里,且子进程继承全部环境变量,泄露面变大
## 遗留
- 生产是否改用内网网关:A10 之前定,届时只改 `llm.baseUrl`
- 是否支持多模型切换(同一个平台配置多个端点):首版不做,等有第二个使用场景再评估

View File

@ -48,5 +48,6 @@ ADR 写完不再修改。要改结论就**新写一份**,并在两份里互相
| [0008](./0008-database-ownership-and-topology.md) | 数据库归属与实例拓扑 | 已接受 | 任务表 A0(决策 D13) |
| [0009](./0009-delivery-shape.md) | 交付形态:纯平台包 + APS 单独部署 | 已接受 | 任务表 A0(决策 D14) |
| [0010](./0010-aps-dev-shape-in-repo.md) | APS 开发期与本仓库同仓不同目录 | 已接受 | 任务表 B0 |
| [0011](./0011-model-endpoint.md) | 模型与端点:DeepSeek + 本机配置文件 | 已接受 | 任务表 A0(决策 D15) |
编号在写之前先在本表登记,避免多人并行时撞号。

View File

@ -28,8 +28,12 @@ export const APS_TABLES = [
'aps_schedule_results',
];
/** 指向 APS 库的连接串特征 */
export const APS_DATABASE_HINTS = [/aps[_-]?db/i, /database\s*=\s*aps/i, /jms_mes/i];
/**
* 指向 APS 库的连接串特征。
* 注意边界:平台自己的环境变量名(APS_DB_PATH、APS_DATABASE_URL)不算越界,
* 只有真正指向业务库的写法(jms_mes、aps_db 这种库名)才拦。
*/
export const APS_DATABASE_HINTS = [/aps[_-]?db\b/, /\bjms_mes\b/];
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs']);
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', 'coverage']);

View File

@ -38,6 +38,11 @@ test('负例:平台代码里出现 APS 表名被拦下', async () => {
);
});
test('正例:平台自己的 APS_DB_PATH 变量不算越界', async () => {
await withFixture({ 'server/config.ts': 'const path = process.env.APS_DB_PATH ?? "~/.aps/aps.sqlite";\nexport const p = path;\n' }, async (dir) => {
assert.deepEqual(await findCrossDatabaseAccess(dir), [], '平台自己的库配置不应被当成访问 APS 业务库');
});
});
test('负例:平台代码里出现 APS 库连接串被拦下', async () => {
await withFixture(
{ 'server/app/config.ts': 'export const url = "postgres://user:pw@db/aps_db";\n' },

84
scripts/config.test.mjs Normal file
View File

@ -0,0 +1,84 @@
/** 配置加载(ADR-0011 / 决策 D15、D16)的用例。 */
import assert from 'node:assert/strict';
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { test } from 'node:test';
import { configFilePath, isEngineConfigured, loadConfig, redactConfig } from '@aps/server/config';
async function withConfigFile(content, run) {
const dir = await mkdtemp(join(tmpdir(), 'aps-config-'));
const file = join(dir, 'config.json');
try {
if (content !== undefined) await writeFile(file, content, 'utf8');
return await run({ dir, file });
} finally {
await rm(dir, { recursive: true, force: true });
}
}
test('没有配置文件与环境变量时用默认值:DeepSeek,密钥为空,引擎不可用', async () => {
await withConfigFile(undefined, async ({ dir, file }) => {
const config = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir });
assert.equal(config.llm.provider, 'deepseek');
assert.equal(config.llm.baseUrl, 'https://api.deepseek.com/v1');
assert.equal(config.llm.model, 'deepseek-chat');
assert.equal(config.llm.apiKey, '');
assert.equal(isEngineConfigured(config), false, '没有密钥时引擎不应被视为可用');
assert.equal(config.database.path, join(dir, '.aps', 'aps.sqlite'), '默认库在用户目录下');
assert.equal(config.server.port, 5199);
});
});
test('配置文件提供端点与密钥', async () => {
const content = JSON.stringify({
llm: { provider: 'compatible', baseUrl: 'http://gw.internal/v1', model: 'internal-chat', apiKey: 'sk-file-123' },
database: { path: 'D:/data/aps.sqlite' },
server: { port: 6000 },
});
await withConfigFile(content, async ({ dir, file }) => {
const config = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir });
assert.equal(config.llm.provider, 'compatible');
assert.equal(config.llm.baseUrl, 'http://gw.internal/v1');
assert.equal(config.llm.model, 'internal-chat');
assert.equal(config.llm.apiKey, 'sk-file-123');
assert.equal(isEngineConfigured(config), true);
assert.equal(config.database.path, 'D:/data/aps.sqlite');
assert.equal(config.server.port, 6000);
});
});
test('环境变量优先于配置文件', async () => {
const content = JSON.stringify({ llm: { model: 'from-file', apiKey: 'sk-file' }, server: { port: 6000 } });
await withConfigFile(content, async ({ dir, file }) => {
const config = loadConfig({
env: { APS_CONFIG_FILE: file, APS_LLM_MODEL: 'from-env', APS_PORT: '7000' },
homeDir: dir,
});
assert.equal(config.llm.model, 'from-env');
assert.equal(config.llm.apiKey, 'sk-file', '未覆盖的字段仍取配置文件');
assert.equal(config.server.port, 7000);
});
});
test('脱敏视图不含密钥明文', async () => {
const content = JSON.stringify({ llm: { apiKey: 'sk-secret-abcdef' } });
await withConfigFile(content, async ({ dir, file }) => {
const config = loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir });
const redacted = JSON.stringify(redactConfig(config));
assert.ok(!redacted.includes('sk-secret-abcdef'), `脱敏后仍能看到密钥:${redacted}`);
assert.ok(redacted.includes('已配置'), redacted);
});
});
test('配置文件损坏时报错,不静默降级', async () => {
await withConfigFile('{ 这不是 JSON', async ({ dir, file }) => {
assert.throws(() => loadConfig({ env: { APS_CONFIG_FILE: file }, homeDir: dir }), /配置文件解析失败/);
});
});
test('配置文件路径:默认在用户目录,可用 APS_CONFIG_FILE 覆盖', () => {
assert.equal(configFilePath({ env: {}, homeDir: 'C:/Users/test' }).replace(/\\/g, '/'), 'C:/Users/test/.aps/config.json');
assert.equal(configFilePath({ env: { APS_CONFIG_FILE: 'D:/cfg/aps.json' }, homeDir: 'C:/Users/test' }), 'D:/cfg/aps.json');
});

View File

@ -1,16 +1,17 @@
/**
* 本地启动平台服务:node server/dist/bin/serve.js(或 pnpm dev:server)。
* A1.5 会把这条命令与界面、数据库串成一条一键启动。
* 配置来源见 ADR-0011:环境变量 > ~/.aps/config.json > 默认值。
*/
import { loadConfig, isEngineConfigured, redactConfig } from '../config.js';
import { startServer } from '../index.js';
const port = Number.parseInt(process.env.APS_PORT ?? '5199', 10);
const host = process.env.APS_HOST ?? '127.0.0.1';
const server = await startServer({ host, port });
const config = loadConfig();
const server = await startServer({ host: config.server.host, port: config.server.port });
console.log(`平台服务已启动:${server.url}(协议 v1)`);
console.log(`健康检查:${server.url}/v1/health`);
console.log(`配置:${JSON.stringify(redactConfig(config))}`);
console.log(isEngineConfigured(config) ? '模型:已配置(真引擎在 A3.3 接入)' : '模型:未配置,当前走假引擎');
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {

107
server/config.ts Normal file
View File

@ -0,0 +1,107 @@
/**
* 平台配置(ADR-0011)。
*
* 读取顺序:环境变量 > 配置文件 > 默认值。
* 配置文件默认在 ~/.aps/config.json(桌面端与本地开发的惯例位置),可用 APS_CONFIG_FILE 指向别处。
* 密钥只在这里出现一次,其余模块拿到的都是配置对象;日志一律用 redactConfig()。
*/
import { existsSync, readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
export type LlmProvider = 'deepseek' | 'openai' | 'compatible';
export interface LlmConfig {
provider: LlmProvider;
baseUrl: string;
model: string;
/** 未配置时为空字符串:引擎不可用,但平台照常启动 */
apiKey: string;
}
export interface PlatformConfig {
llm: LlmConfig;
database: { path: string };
server: { host: string; port: number };
/** 客户端构建产物目录,可覆盖(默认仓库内 client/app/dist) */
clientDist?: string;
}
export interface LoadConfigInput {
env?: NodeJS.ProcessEnv;
/** 便于测试:覆盖用户主目录 */
homeDir?: string;
}
const DEFAULT_BASE_URLS: Record<string, string> = {
deepseek: 'https://api.deepseek.com/v1',
openai: 'https://api.openai.com/v1',
};
export function configFilePath(input: LoadConfigInput = {}): string {
const env = input.env ?? process.env;
const explicit = env.APS_CONFIG_FILE;
if (explicit && explicit.trim() !== '') return explicit;
return join(input.homeDir ?? homedir(), '.aps', 'config.json');
}
interface FileConfig {
llm?: Partial<LlmConfig>;
database?: { path?: string };
server?: { host?: string; port?: number };
clientDist?: string;
}
function readFileConfig(path: string): FileConfig {
if (!existsSync(path)) return {};
try {
return JSON.parse(readFileSync(path, 'utf8')) as FileConfig;
} catch (error) {
// 配置文件坏了要吵:静默忽略会让密钥问题变得难以排查
throw new Error(`配置文件解析失败:${path}(${error instanceof Error ? error.message : '未知错误'})`, { cause: error });
}
}
export function loadConfig(input: LoadConfigInput = {}): PlatformConfig {
const env = input.env ?? process.env;
const file = readFileConfig(configFilePath(input));
const provider = (env.APS_LLM_PROVIDER ?? file.llm?.provider ?? 'deepseek') as LlmProvider;
const baseUrl = env.APS_LLM_BASE_URL ?? file.llm?.baseUrl ?? DEFAULT_BASE_URLS[provider] ?? '';
const model = env.APS_LLM_MODEL ?? file.llm?.model ?? 'deepseek-chat';
const apiKey = env.APS_LLM_API_KEY ?? file.llm?.apiKey ?? '';
const defaultDbPath = join(input.homeDir ?? homedir(), '.aps', 'aps.sqlite');
const dbPath = env.APS_DB_PATH ?? file.database?.path ?? defaultDbPath;
const port = Number.parseInt(env.APS_PORT ?? '', 10);
return {
llm: { provider, baseUrl, model, apiKey },
database: { path: dbPath },
server: {
host: env.APS_HOST ?? file.server?.host ?? '127.0.0.1',
port: Number.isFinite(port) ? port : (file.server?.port ?? 5199),
},
...(env.APS_CLIENT_DIST ?? file.clientDist ?? '' ? { clientDist: env.APS_CLIENT_DIST ?? file.clientDist ?? '' } : {}),
};
}
/** 引擎是否可用:只有配了密钥才算可用 */
export function isEngineConfigured(config: PlatformConfig): boolean {
return config.llm.apiKey.trim() !== '';
}
/** 脱敏视图:日志与启动横幅只能用它 */
export function redactConfig(config: PlatformConfig): Record<string, unknown> {
return {
llm: {
provider: config.llm.provider,
baseUrl: config.llm.baseUrl,
model: config.llm.model,
apiKey: config.llm.apiKey === '' ? '(未配置)' : `(已配置,长度 ${config.llm.apiKey.length})`,
},
database: { path: config.database.path },
server: { host: config.server.host, port: config.server.port },
};
}

View File

@ -8,6 +8,7 @@ import type { Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import type { AgentRuntime } from './agent/runtime.js';
import { loadConfig } from './config.js';
import { ApplicationService } from './app/service.js';
import { createHttpServer } from './entry/http.js';
@ -33,8 +34,9 @@ export interface RunningServer {
}
export async function startServer(options: StartOptions = {}): Promise<RunningServer> {
const host = options.host ?? '127.0.0.1';
const port = options.port ?? 5199;
const config = loadConfig();
const host = options.host ?? config.server.host;
const port = options.port ?? config.server.port;
const service = new ApplicationService({
...(options.version === undefined ? {} : { version: options.version }),
...(options.runtime === undefined ? {} : { runtime: options.runtime }),

View File

@ -10,6 +10,10 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./config": {
"types": "./dist/config.d.ts",
"default": "./dist/config.js"
},
"./agent/runtime": {
"types": "./dist/agent/runtime.d.ts",
"default": "./dist/agent/runtime.js"

View File

@ -6,7 +6,7 @@
"tsBuildInfoFile": "dist/.tsbuildinfo",
"types": ["node"]
},
"include": ["index.ts", "bin", "entry", "app", "agent", "core", "workflows"],
"include": ["index.ts", "config.ts", "bin", "entry", "app", "agent", "core", "workflows"],
"exclude": ["provider-sdk"],
"references": [
{ "path": "../contracts" },