diff --git a/.env.example b/.env.example index 7d357cc..4b50e7b 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/scripts/capability-visibility.test.mjs b/scripts/capability-visibility.test.mjs new file mode 100644 index 0000000..f2f69c8 --- /dev/null +++ b/scripts/capability-visibility.test.mjs @@ -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; + } +}); \ No newline at end of file diff --git a/server/app/service.ts b/server/app/service.ts index 8a1b46e..4e6a879 100644 --- a/server/app/service.ts +++ b/server/app/service.ts @@ -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(); + /** 会话 → 工具集指纹:身份或可见能力变了就重建运行时会话 */ + private readonly runtimeFingerprints = new Map(); 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 { 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; } } \ No newline at end of file diff --git a/server/config.ts b/server/config.ts index 42820c1..9b78d20 100644 --- a/server/config.ts +++ b/server/config.ts @@ -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; engine?: Partial; + capabilities?: Partial; 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 { 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, diff --git a/server/core/registry/visibility.ts b/server/core/registry/visibility.ts new file mode 100644 index 0000000..81e3d7e --- /dev/null +++ b/server/core/registry/visibility.ts @@ -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(','); +} \ No newline at end of file diff --git a/server/entry/dev-identity.ts b/server/entry/dev-identity.ts index ce36e9d..dbbc79f 100644 --- a/server/entry/dev-identity.ts +++ b/server/entry/dev-identity.ts @@ -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, }; diff --git a/server/entry/http.ts b/server/entry/http.ts index ca91f5b..342c12a 100644 --- a/server/entry/http.ts +++ b/server/entry/http.ts @@ -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(); diff --git a/server/index.ts b/server/index.ts index 93ee556..85edc65 100644 --- a/server/index.ts +++ b/server/index.ts @@ -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