feat(core): 按身份过滤能力与工具白名单(A3.5)

新增 server/core/registry/visibility.ts:能力可见性规则(defaultAllow / denyWrite / grants 按 userId、tenantId 与能力 id 前缀授权)。能力清单与引擎工具集走同一次过滤,避免模型看到用户看不到的能力;运行时会话缓存加工具集指纹,身份或可见能力变化就重建(不跨身份复用工具集)。会话加归属校验:读、发消息、取消、订阅事件流、按会话取能力清单,非归属者一律 403。配置层加 capabilities 段(默认 defaultAllow=true 供开发使用,A4.1 接真实身份后应改 false);开发身份支持 x-aps-user / x-aps-tenant 头覆盖,便于多身份联调与权限用例。测试 66 → 70:白名单同源、跨身份隔离、工具集变化重建、只读模式与未登记能力。
This commit is contained in:
zh 2026-09-28 22:17:25 +08:00
parent 723e5d7ccd
commit d5df4f5408
8 changed files with 336 additions and 23 deletions

View File

@ -26,6 +26,12 @@ APS_PORT=5199
# 默认 ~/.aps/aps.sqlite;服务端部署时显式指定
# APS_DB_PATH=
# ---- 能力可见性(A3.5)----
# 默认允许:开发期为 1;接入真实身份(A4.1)后应为 0,由 ~/.aps/config.json 的 capabilities.grants 逐条授权
# APS_CAPABILITY_DEFAULT_ALLOW=1
# 只读模式:1=屏蔽所有写能力(演示或只读部署用)
# APS_CAPABILITY_DENY_WRITE=0
# ---- 引擎选择 ----
# 留空 = 有密钥就用 Pi、没密钥用假引擎;也可显式写 fake | pi
# APS_ENGINE=

View File

@ -0,0 +1,174 @@
/**
* 能力可见性用例(任务 A3.5)。
*
* 验收:不同身份看到的工具集不同,切换身份重建会话。
* 另外锁住两条容易漏的边界:能力清单与引擎工具集必须同源;白名单外的能力对模型也不可见。
*/
import assert from 'node:assert/strict';
import { test } from 'node:test';
import { createFakeRuntime } from '@aps/server/agent/fake-runtime';
const A = { user: 'user-a', tenant: 't-1' };
const B = { user: 'user-b', tenant: 't-1' };
/** 记录每次 createSession 收到的工具集,用来断言「引擎看到的工具」而不是只看 HTTP 清单 */
function recordingRuntime() {
const calls = [];
const runtime = createFakeRuntime({ name: 'recording', turns: [{ text: ['收到。'] }] });
const original = runtime.createSession.bind(runtime);
runtime.createSession = async (input) => {
calls.push({ userId: input.subject.userId, tools: input.tools.map((tool) => tool.capabilityId).sort() });
return original(input);
};
return { runtime, calls };
}
async function startPlatform(visibility, runtime) {
process.env.APS_DEV_PROVIDER = '1';
const { startServer } = await import('../server/dist/index.js');
return startServer({
host: '127.0.0.1',
port: 0,
version: 'test',
databasePath: ':memory:',
engine: 'fake',
...(runtime === undefined ? {} : { runtime }),
capabilityVisibility: visibility,
});
}
const headersFor = (who) => ({ 'content-type': 'application/json', 'x-aps-user': who.user, 'x-aps-tenant': who.tenant });
async function createSession(server, who) {
const response = await fetch(`${server.url}/v1/sessions`, {
method: 'POST',
headers: headersFor(who),
body: JSON.stringify({ scope: 'project', projectId: 'p-default', title: `${who.user} 的会话` }),
});
assert.equal(response.status, 200);
return (await response.json()).session.sessionId;
}
const capabilitiesOf = async (server, who) => {
const response = await fetch(`${server.url}/v1/capabilities`, { headers: headersFor(who) });
assert.equal(response.status, 200);
return (await response.json()).capabilities.map((item) => item.id).sort();
};
test('白名单决定可见能力:清单与引擎工具集同源', async () => {
const { runtime, calls } = recordingRuntime();
const server = await startPlatform(
{
defaultAllow: false,
denyWrite: false,
grants: [
{ userId: A.user, allow: ['fake.echo'] },
{ userId: B.user, allow: ['fake.write'] },
],
},
runtime,
);
try {
assert.deepEqual(await capabilitiesOf(server, A), ['fake.echo']);
assert.deepEqual(await capabilitiesOf(server, B), ['fake.write']);
// 各发一轮,看引擎实际拿到的工具
for (const who of [A, B]) {
const sessionId = await createSession(server, who);
const response = await fetch(`${server.url}/v1/sessions/${sessionId}/messages`, {
method: 'POST',
headers: headersFor(who),
body: JSON.stringify({ text: '你好', idempotencyKey: `idem-${who.user}-0001` }),
});
assert.equal(response.status, 202);
}
await new Promise((resolve) => setTimeout(resolve, 300));
const forA = calls.find((call) => call.userId === A.user);
const forB = calls.find((call) => call.userId === B.user);
assert.deepEqual(forA?.tools, ['fake.echo'], '引擎给 A 的工具应与 A 的能力清单一致');
assert.deepEqual(forB?.tools, ['fake.write'], '引擎给 B 的工具应与 B 的能力清单一致');
assert.notDeepEqual(forA?.tools, forB?.tools, '不同身份的工具集必须不同');
} finally {
await server.close();
delete process.env.APS_DEV_PROVIDER;
}
});
test('别人的会话不能访问,更不能复用它的运行时会话', async () => {
const { runtime, calls } = recordingRuntime();
const server = await startPlatform({ defaultAllow: true, denyWrite: false, grants: [] }, runtime);
try {
const sessionId = await createSession(server, A);
const read = await fetch(`${server.url}/v1/sessions/${sessionId}`, { headers: headersFor(B) });
assert.equal(read.status, 403, 'B 读 A 的会话应被拒');
assert.equal((await read.json()).error.code, 'forbidden');
const send = await fetch(`${server.url}/v1/sessions/${sessionId}/messages`, {
method: 'POST',
headers: headersFor(B),
body: JSON.stringify({ text: '插一句', idempotencyKey: 'idem-b-0001' }),
});
assert.equal(send.status, 403, 'B 不能在 A 的会话里发消息');
assert.equal(calls.length, 0, '被拒的请求不应创建任何运行时会话(工具集不能跨身份复用)');
const caps = await fetch(`${server.url}/v1/capabilities?sessionId=${sessionId}`, { headers: headersFor(B) });
assert.equal(caps.status, 403, '按会话取能力清单也要校验归属');
} finally {
await server.close();
delete process.env.APS_DEV_PROVIDER;
}
});
test('可见能力变化后重建运行时会话(工具集不会被沿用)', async () => {
const { runtime, calls } = recordingRuntime();
const server = await startPlatform({ defaultAllow: true, denyWrite: false, grants: [] }, runtime);
try {
const sessionId = await createSession(server, A);
const send = () =>
fetch(`${server.url}/v1/sessions/${sessionId}/messages`, {
method: 'POST',
headers: headersFor(A),
body: JSON.stringify({ text: '你好', idempotencyKey: `idem-${Math.random().toString(36).slice(2)}` }),
});
assert.equal((await send()).status, 202);
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(calls.length, 1, '第一次调用创建运行时会话');
// 注册一个新 Provider:可见工具集变了
const { createFakeProvider } = await import('@aps/provider-sdk/testing');
await server.service.registerProvider(createFakeProvider('extra'));
assert.equal((await send()).status, 202);
await new Promise((resolve) => setTimeout(resolve, 200));
assert.equal(calls.length, 2, '工具集变化后必须重建运行时会话');
assert.ok(calls[1].tools.length > calls[0].tools.length, `第二次应拿到更多工具:${calls[0].tools.length} → ${calls[1].tools.length}`);
} finally {
await server.close();
delete process.env.APS_DEV_PROVIDER;
}
});
test('只读模式屏蔽写能力;白名单里不存在的能力不会出现', async () => {
const server = await startPlatform(
{
defaultAllow: true,
denyWrite: true,
grants: [{ userId: A.user, allow: ['fake.nonexistent'] }],
},
createFakeRuntime({ name: 'noop' }),
);
try {
const visible = await capabilitiesOf(server, A);
assert.ok(!visible.includes('fake.write'), `只读模式不应出现写能力:${visible.join(',')}`);
assert.ok(!visible.includes('fake.nonexistent'), '未登记的能力不该被授权表凭空造出来');
assert.ok(visible.includes('fake.echo'), '读能力应保留');
} finally {
await server.close();
delete process.env.APS_DEV_PROVIDER;
}
});

View File

@ -13,10 +13,15 @@ import type { AgentRuntime, RuntimeSession } from '../agent/runtime.js';
import { createRulePlanner, plannerToFakeTurn } from '../agent/rule-planner.js';
import { toToolDescriptors } from '../agent/tools.js';
import type { SessionRepository } from '../core/storage/sessions.js';
import {
capabilityFingerprint,
visibleCapabilities,
type CapabilityVisibility,
} from '../core/registry/visibility.js';
import { runTurn } from './turn-runner.js';
import { PLATFORM_SYSTEM_PROMPT } from './system-prompt.js';
import type { Provider } from '@aps/provider-sdk';
import { badRequest, conflict } from './errors.js';
import { AppError, badRequest, conflict } from './errors.js';
import { ProjectStore } from './projects.js';
import { newId, SessionStore, type SessionRecord } from './sessions.js';
import type { Subject } from './subject.js';
@ -27,6 +32,8 @@ export interface ApplicationServiceOptions {
runtime?: AgentRuntime;
/** 会话持久化(A3.7):不传则纯内存,测试与临时运行用 */
sessionRepository?: SessionRepository;
/** 能力可见性规则(A3.5):不传则开发期默认全部可见 */
capabilityVisibility?: CapabilityVisibility;
}
export class ApplicationService {
@ -37,12 +44,16 @@ export class ApplicationService {
readonly registry = new CapabilityRegistry();
readonly core: Core;
private readonly runtime: AgentRuntime;
private readonly visibility: CapabilityVisibility;
/** 每个平台会话一个运行时会话(ADR-0006:首版一进程一会话) */
private readonly runtimeSessions = new Map<string, RuntimeSession>();
/** 会话 → 工具集指纹:身份或可见能力变了就重建运行时会话 */
private readonly runtimeFingerprints = new Map<string, string>();
constructor(options: ApplicationServiceOptions = {}) {
this.version = options.version ?? '0.1.0';
this.sessions = new SessionStore(options.sessionRepository);
this.visibility = options.capabilityVisibility ?? { defaultAllow: true, denyWrite: false, grants: [] };
this.core = createCore({ registry: this.registry });
this.runtime = options.runtime ?? createFakeRuntime({ planner: plannerToFakeTurn(createRulePlanner()) });
}
@ -95,17 +106,29 @@ export class ApplicationService {
return { session: record.summary };
}
getSession(sessionId: string): SessionDetailResponse {
const record = this.sessions.require(sessionId);
getSession(subject: Subject, sessionId: string): SessionDetailResponse {
const record = this.assertSessionAccess(subject, sessionId);
return { session: record.summary, messages: record.messages };
}
/** 这个身份能看到的能力:能力清单与引擎工具列表都走这里,保证两者一致 */
private visibleCapabilitiesFor(subject: Subject) {
return visibleCapabilities(this.registry.list(), this.visibility, { userId: subject.userId, tenantId: subject.tenantId });
}
/** 会话归属校验:不是自己的会话一律拒绝(协作归业务系统,见 D9) */
assertSessionAccess(subject: Subject, sessionId: string): SessionRecord {
const record = this.sessions.require(sessionId);
if (record.owner.userId !== subject.userId) {
throw new AppError('forbidden', '这是别人的会话', { status: 403 });
}
return record;
}
capabilities(subject: Subject, sessionId?: string): CapabilitiesResponse {
if (sessionId) this.sessions.require(sessionId); // 会话不存在直接 404
// A4.1 起在这里按身份与策略过滤;当前返回能力目录里的全部能力
void subject;
if (sessionId) this.assertSessionAccess(subject, sessionId);
return {
capabilities: this.registry.list().map((capability) => {
capabilities: this.visibleCapabilitiesFor(subject).map((capability) => {
const providerId = this.registry.resolve(capability.id)?.providerId ?? '';
return {
id: capability.id,
@ -121,8 +144,7 @@ export class ApplicationService {
}
sendMessage(subject: Subject, sessionId: string, request: SendMessageRequest): SendMessageResponse {
const record = this.sessions.require(sessionId);
void subject; // A4.1 起用于策略判定,A1 阶段只做存在性校验
const record = this.assertSessionAccess(subject, sessionId);
if (typeof request.text !== 'string' || request.text.trim() === '') throw badRequest('text 不能为空');
if (typeof request.idempotencyKey !== 'string' || request.idempotencyKey.length < 8) {
@ -164,8 +186,8 @@ export class ApplicationService {
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
});
}
cancel(sessionId: string): CancelResponse {
const record = this.sessions.require(sessionId);
cancel(subject: Subject, sessionId: string): CancelResponse {
const record = this.assertSessionAccess(subject, sessionId);
if (record.state !== 'running') return { state: 'idle' };
record.state = 'cancelling';
// 先让界面看到「正在取消」,再中止引擎与执行
@ -185,8 +207,8 @@ export class ApplicationService {
heartbeat(sessionId: string): void {
this.sessions.require(sessionId).bus.publish({ sessionId, type: 'heartbeat', data: {} });
}
subscribe(sessionId: string, since: number, listener: (event: PlatformEvent) => void): () => void {
return this.sessions.require(sessionId).bus.subscribe(since, listener);
subscribe(subject: Subject, sessionId: string, since: number, listener: (event: PlatformEvent) => void): () => void {
return this.assertSessionAccess(subject, sessionId).bus.subscribe(since, listener);
}
lastSeq(sessionId: string): number {
@ -230,17 +252,25 @@ export class ApplicationService {
/** 取(或创建)平台会话对应的运行时会话:工具来自能力目录,历史来自平台记录 */
private async runtimeSessionFor(record: SessionRecord): Promise<RuntimeSession> {
const sessionId = record.summary.sessionId;
const capabilities = this.visibleCapabilitiesFor(record.owner);
const fingerprint = `${record.owner.userId}|${capabilityFingerprint(capabilities)}`;
const existing = this.runtimeSessions.get(sessionId);
if (existing) return existing;
// 身份或工具集变了就重建:工具集不能跨身份复用(架构设计 5.1「按身份给工具」)
if (existing && this.runtimeFingerprints.get(sessionId) === fingerprint) return existing;
if (existing) {
await existing.dispose().catch(() => undefined);
this.runtimeSessions.delete(sessionId);
}
const created = await this.runtime.createSession({
sessionId,
subject: { userId: record.owner.userId, tenantId: record.owner.tenantId },
projectId: record.summary.projectId,
system: PLATFORM_SYSTEM_PROMPT,
tools: toToolDescriptors(this.registry.list()),
tools: toToolDescriptors(capabilities),
history: record.messages,
});
this.runtimeSessions.set(sessionId, created);
this.runtimeFingerprints.set(sessionId, fingerprint);
return created;
}
}

View File

@ -19,6 +19,15 @@ export interface LlmConfig {
apiKey: string;
}
export interface CapabilityConfig {
/** 开发期默认 true;接入真实身份后应为 false,由授权表逐条给出 */
defaultAllow: boolean;
/** 只读模式:屏蔽写能力 */
denyWrite: boolean;
/** 授权表:谁能看到哪些能力(按 id 前缀) */
grants: Array<{ userId?: string; tenantId?: string; allow: string[] }>;
}
export interface EngineConfig {
/** fake = 假引擎(默认);pi = Pi 子进程(A3.3) */
kind: 'fake' | 'pi';
@ -27,6 +36,7 @@ export interface EngineConfig {
export interface PlatformConfig {
llm: LlmConfig;
engine: EngineConfig;
capabilities: CapabilityConfig;
database: { path: string };
server: { host: string; port: number };
/** 客户端构建产物目录,可覆盖(默认仓库内 client/app/dist) */
@ -54,6 +64,7 @@ export function configFilePath(input: LoadConfigInput = {}): string {
interface FileConfig {
llm?: Partial<LlmConfig>;
engine?: Partial<EngineConfig>;
capabilities?: Partial<CapabilityConfig>;
database?: { path?: string };
server?: { host?: string; port?: number };
clientDist?: string;
@ -92,6 +103,12 @@ export function loadConfig(input: LoadConfigInput = {}): PlatformConfig {
return {
llm: { provider, baseUrl, model, apiKey },
engine,
capabilities: {
// 环境变量优先;1/true 为真,0/false 为假
defaultAllow: !['0', 'false'].includes(String(env.APS_CAPABILITY_DEFAULT_ALLOW ?? file.capabilities?.defaultAllow ?? true).toLowerCase()),
denyWrite: ['1', 'true'].includes(String(env.APS_CAPABILITY_DENY_WRITE ?? file.capabilities?.denyWrite ?? false).toLowerCase()),
grants: file.capabilities?.grants ?? [],
},
database: { path: dbPath },
server: {
host: env.APS_HOST ?? file.server?.host ?? '127.0.0.1',
@ -110,6 +127,7 @@ export function isEngineConfigured(config: PlatformConfig): boolean {
export function redactConfig(config: PlatformConfig): Record<string, unknown> {
return {
engine: { kind: config.engine.kind },
capabilities: { defaultAllow: config.capabilities.defaultAllow, denyWrite: config.capabilities.denyWrite, grants: config.capabilities.grants.length },
llm: {
provider: config.llm.provider,
baseUrl: config.llm.baseUrl,

View File

@ -0,0 +1,66 @@
/**
* 能力可见性(任务 A3.5):按身份与白名单决定谁能看到哪些能力。
*
* 两条边界(架构设计 5.1):
* - 工具列表与能力清单必须来自同一次过滤,否则模型能看到用户看不到的能力
* - 权限归业务系统(D9),这里只管「平台把哪些能力暴露给这个身份」,不管业务系统内部的角色
*
* 默认策略:开发期 defaultAllow=true(未配白名单时全部可见)。接入真实身份(A4.1)后应改成 false,
* 由授权表逐条给出——这与治理模型里「未登记默认拒绝」是同一条原则。
*/
import type { Capability } from '@aps/provider-sdk';
export interface CapabilityGrant {
/** 空表示不限用户 */
userId?: string;
/** 空表示不限租户 */
tenantId?: string;
/** 允许的能力 id 前缀,如 aps.schedule 或 fake. */
allow: string[];
}
export interface CapabilityVisibility {
defaultAllow: boolean;
denyWrite: boolean;
grants: CapabilityGrant[];
}
export interface VisibilitySubject {
userId: string;
tenantId: string;
}
const matchesPrefix = (capabilityId: string, prefixes: string[]): boolean =>
prefixes.some((prefix) => (prefix.endsWith('*') ? capabilityId.startsWith(prefix.slice(0, -1)) : capabilityId === prefix));
const grantApplies = (grant: CapabilityGrant, subject: VisibilitySubject): boolean => {
if (grant.userId !== undefined && grant.userId !== subject.userId) return false;
if (grant.tenantId !== undefined && grant.tenantId !== subject.tenantId) return false;
return true;
};
/** 这个身份能看到这个能力吗 */
export function isCapabilityVisible(capability: Capability, visibility: CapabilityVisibility, subject: VisibilitySubject): boolean {
// 写能力在只读模式下对所有人不可见(演示、审计或只读部署用)
if (visibility.denyWrite && capability.effect === 'write') return false;
const granted = visibility.grants.some((grant) => grantApplies(grant, subject) && matchesPrefix(capability.id, grant.allow));
if (granted) return true;
if (!visibility.defaultAllow) return false;
// 默认允许模式下仍可用 denyWrite 收口;白名单里的 deny 规则暂不做,等 A4 的策略层
return true;
}
export function visibleCapabilities(
capabilities: Capability[],
visibility: CapabilityVisibility,
subject: VisibilitySubject,
): Capability[] {
return capabilities.filter((capability) => isCapabilityVisible(capability, visibility, subject));
}
/** 能力集的指纹:用于判断运行时会话是否需要重建(工具集变了就得重建) */
export function capabilityFingerprint(capabilities: Capability[]): string {
return [...capabilities.map((capability) => capability.id)].sort().join(',');
}

View File

@ -9,11 +9,18 @@ import type { IncomingHttpHeaders } from 'node:http';
import type { Subject } from '../app/subject.js';
export function resolveSubject(headers: IncomingHttpHeaders): Subject {
// 测试与多身份联调用:x-aps-user / x-aps-tenant 覆盖开发身份(A4.1 接真实登录后移除)
const header = (name: string): string => {
const value = headers[name];
return String(Array.isArray(value) ? value[0] : (value ?? '')).trim();
};
const devUser = header('x-aps-user');
const devTenant = header('x-aps-tenant');
const raw = headers['x-aps-client'];
const clientKind = String(Array.isArray(raw) ? raw[0] : (raw ?? '')).toLowerCase() === 'desktop' ? 'desktop' : 'browser';
return {
userId: 'dev-user',
tenantId: 'dev-tenant',
userId: devUser === '' ? 'dev-user' : devUser,
tenantId: devTenant === '' ? 'dev-tenant' : devTenant,
displayName: '开发用户',
clientKind,
};

View File

@ -7,6 +7,8 @@
*/
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
import type { Subject } from '../app/subject.js';
import { ENDPOINT_SPECS, type CreateSessionRequest, type EndpointSpec, type PlatformEvent, type SendMessageRequest } from '@aps/contracts';
import { AppError } from '../app/errors.js';
@ -87,13 +89,13 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, service:
case 'POST /v1/sessions':
return sendJson(res, 200, service.createSession(subject, (await readJson(req)) as CreateSessionRequest));
case 'GET /v1/sessions/{id}':
return sendJson(res, 200, service.getSession(sessionId));
return sendJson(res, 200, service.getSession(subject, sessionId));
case 'GET /v1/sessions/{id}/events':
return openEventStream(req, res, service, sessionId);
return openEventStream(req, res, service, sessionId, subject);
case 'POST /v1/sessions/{id}/messages':
return sendJson(res, 202, service.sendMessage(subject, sessionId, (await readJson(req)) as SendMessageRequest));
case 'POST /v1/sessions/{id}/cancel':
return sendJson(res, 200, service.cancel(sessionId));
return sendJson(res, 200, service.cancel(subject, sessionId));
case 'GET /v1/capabilities':
return sendJson(res, 200, service.capabilities(subject, sessionFilter));
case 'GET /v1/executions/{executionId}': {
@ -122,14 +124,20 @@ function matchRoute(method: string, pathname: string): { route: Route; params: M
return undefined;
}
function openEventStream(req: IncomingMessage, res: ServerResponse, service: ApplicationService, sessionId: string): void {
function openEventStream(
req: IncomingMessage,
res: ServerResponse,
service: ApplicationService,
sessionId: string,
subject: Subject,
): void {
const url = new URL(req.url ?? '/', 'http://localhost');
const headerValue = req.headers['last-event-id'];
const raw = url.searchParams.get('since') ?? (Array.isArray(headerValue) ? headerValue[0] : headerValue) ?? '0';
const parsed = Number.parseInt(String(raw), 10);
const since = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
service.lastSeq(sessionId); // 会话不存在时先 404
service.assertSessionAccess(subject, sessionId); // 会话不存在或不属于当前身份时先拒
res.writeHead(200, {
'content-type': 'text/event-stream; charset=utf-8',
@ -139,7 +147,7 @@ function openEventStream(req: IncomingMessage, res: ServerResponse, service: App
});
res.write(': connected\n\n'); // 让客户端立刻知道连接已建立
const unsubscribe = service.subscribe(sessionId, since, (event) => writeEvent(res, event));
const unsubscribe = service.subscribe(subject, sessionId, since, (event: PlatformEvent) => writeEvent(res, event));
const heartbeat = setInterval(() => service.heartbeat(sessionId), HEARTBEAT_MS);
heartbeat.unref();

View File

@ -9,6 +9,7 @@ import type { AddressInfo } from 'node:net';
import type { AgentRuntime } from './agent/runtime.js';
import { loadConfig } from './config.js';
import type { CapabilityVisibility } from './core/registry/visibility.js';
import { openDatabase, type Database } from './core/storage/db.js';
import { SessionRepository } from './core/storage/sessions.js';
import { ApplicationService } from './app/service.js';
@ -28,6 +29,8 @@ export interface StartOptions {
databasePath?: string;
/** 引擎覆盖:测试必须传 'fake',避免读到本机的真实模型配置 */
engine?: 'fake' | 'pi';
/** 能力可见性覆盖(A3.5):不传则用配置里的规则 */
capabilityVisibility?: CapabilityVisibility;
}
export interface RunningServer {
@ -52,6 +55,7 @@ export async function startServer(options: StartOptions = {}): Promise<RunningSe
...(options.version === undefined ? {} : { version: options.version }),
runtime,
sessionRepository: new SessionRepository(database),
capabilityVisibility: options.capabilityVisibility ?? config.capabilities,
});
// 开发期可选:APS_DEV_PROVIDER=1 时注册假 Provider,用于本地联调与演示(默认不注册,能力清单为空)
if (process.env.APS_DEV_PROVIDER === '1') {