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:
parent
723e5d7ccd
commit
d5df4f5408
|
|
@ -26,6 +26,12 @@ APS_PORT=5199
|
||||||
# 默认 ~/.aps/aps.sqlite;服务端部署时显式指定
|
# 默认 ~/.aps/aps.sqlite;服务端部署时显式指定
|
||||||
# APS_DB_PATH=
|
# 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
|
# 留空 = 有密钥就用 Pi、没密钥用假引擎;也可显式写 fake | pi
|
||||||
# APS_ENGINE=
|
# APS_ENGINE=
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -13,10 +13,15 @@ import type { AgentRuntime, RuntimeSession } from '../agent/runtime.js';
|
||||||
import { createRulePlanner, plannerToFakeTurn } from '../agent/rule-planner.js';
|
import { createRulePlanner, plannerToFakeTurn } from '../agent/rule-planner.js';
|
||||||
import { toToolDescriptors } from '../agent/tools.js';
|
import { toToolDescriptors } from '../agent/tools.js';
|
||||||
import type { SessionRepository } from '../core/storage/sessions.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 { runTurn } from './turn-runner.js';
|
||||||
import { PLATFORM_SYSTEM_PROMPT } from './system-prompt.js';
|
import { PLATFORM_SYSTEM_PROMPT } from './system-prompt.js';
|
||||||
import type { Provider } from '@aps/provider-sdk';
|
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 { ProjectStore } from './projects.js';
|
||||||
import { newId, SessionStore, type SessionRecord } from './sessions.js';
|
import { newId, SessionStore, type SessionRecord } from './sessions.js';
|
||||||
import type { Subject } from './subject.js';
|
import type { Subject } from './subject.js';
|
||||||
|
|
@ -27,6 +32,8 @@ export interface ApplicationServiceOptions {
|
||||||
runtime?: AgentRuntime;
|
runtime?: AgentRuntime;
|
||||||
/** 会话持久化(A3.7):不传则纯内存,测试与临时运行用 */
|
/** 会话持久化(A3.7):不传则纯内存,测试与临时运行用 */
|
||||||
sessionRepository?: SessionRepository;
|
sessionRepository?: SessionRepository;
|
||||||
|
/** 能力可见性规则(A3.5):不传则开发期默认全部可见 */
|
||||||
|
capabilityVisibility?: CapabilityVisibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ApplicationService {
|
export class ApplicationService {
|
||||||
|
|
@ -37,12 +44,16 @@ export class ApplicationService {
|
||||||
readonly registry = new CapabilityRegistry();
|
readonly registry = new CapabilityRegistry();
|
||||||
readonly core: Core;
|
readonly core: Core;
|
||||||
private readonly runtime: AgentRuntime;
|
private readonly runtime: AgentRuntime;
|
||||||
|
private readonly visibility: CapabilityVisibility;
|
||||||
/** 每个平台会话一个运行时会话(ADR-0006:首版一进程一会话) */
|
/** 每个平台会话一个运行时会话(ADR-0006:首版一进程一会话) */
|
||||||
private readonly runtimeSessions = new Map<string, RuntimeSession>();
|
private readonly runtimeSessions = new Map<string, RuntimeSession>();
|
||||||
|
/** 会话 → 工具集指纹:身份或可见能力变了就重建运行时会话 */
|
||||||
|
private readonly runtimeFingerprints = new Map<string, string>();
|
||||||
|
|
||||||
constructor(options: ApplicationServiceOptions = {}) {
|
constructor(options: ApplicationServiceOptions = {}) {
|
||||||
this.version = options.version ?? '0.1.0';
|
this.version = options.version ?? '0.1.0';
|
||||||
this.sessions = new SessionStore(options.sessionRepository);
|
this.sessions = new SessionStore(options.sessionRepository);
|
||||||
|
this.visibility = options.capabilityVisibility ?? { defaultAllow: true, denyWrite: false, grants: [] };
|
||||||
this.core = createCore({ registry: this.registry });
|
this.core = createCore({ registry: this.registry });
|
||||||
this.runtime = options.runtime ?? createFakeRuntime({ planner: plannerToFakeTurn(createRulePlanner()) });
|
this.runtime = options.runtime ?? createFakeRuntime({ planner: plannerToFakeTurn(createRulePlanner()) });
|
||||||
}
|
}
|
||||||
|
|
@ -95,17 +106,29 @@ export class ApplicationService {
|
||||||
return { session: record.summary };
|
return { session: record.summary };
|
||||||
}
|
}
|
||||||
|
|
||||||
getSession(sessionId: string): SessionDetailResponse {
|
getSession(subject: Subject, sessionId: string): SessionDetailResponse {
|
||||||
const record = this.sessions.require(sessionId);
|
const record = this.assertSessionAccess(subject, sessionId);
|
||||||
return { session: record.summary, messages: record.messages };
|
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 {
|
capabilities(subject: Subject, sessionId?: string): CapabilitiesResponse {
|
||||||
if (sessionId) this.sessions.require(sessionId); // 会话不存在直接 404
|
if (sessionId) this.assertSessionAccess(subject, sessionId);
|
||||||
// A4.1 起在这里按身份与策略过滤;当前返回能力目录里的全部能力
|
|
||||||
void subject;
|
|
||||||
return {
|
return {
|
||||||
capabilities: this.registry.list().map((capability) => {
|
capabilities: this.visibleCapabilitiesFor(subject).map((capability) => {
|
||||||
const providerId = this.registry.resolve(capability.id)?.providerId ?? '';
|
const providerId = this.registry.resolve(capability.id)?.providerId ?? '';
|
||||||
return {
|
return {
|
||||||
id: capability.id,
|
id: capability.id,
|
||||||
|
|
@ -121,8 +144,7 @@ export class ApplicationService {
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(subject: Subject, sessionId: string, request: SendMessageRequest): SendMessageResponse {
|
sendMessage(subject: Subject, sessionId: string, request: SendMessageRequest): SendMessageResponse {
|
||||||
const record = this.sessions.require(sessionId);
|
const record = this.assertSessionAccess(subject, sessionId);
|
||||||
void subject; // A4.1 起用于策略判定,A1 阶段只做存在性校验
|
|
||||||
|
|
||||||
if (typeof request.text !== 'string' || request.text.trim() === '') throw badRequest('text 不能为空');
|
if (typeof request.text !== 'string' || request.text.trim() === '') throw badRequest('text 不能为空');
|
||||||
if (typeof request.idempotencyKey !== 'string' || request.idempotencyKey.length < 8) {
|
if (typeof request.idempotencyKey !== 'string' || request.idempotencyKey.length < 8) {
|
||||||
|
|
@ -164,8 +186,8 @@ export class ApplicationService {
|
||||||
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
|
...(input.timeoutMs === undefined ? {} : { timeoutMs: input.timeoutMs }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
cancel(sessionId: string): CancelResponse {
|
cancel(subject: Subject, sessionId: string): CancelResponse {
|
||||||
const record = this.sessions.require(sessionId);
|
const record = this.assertSessionAccess(subject, sessionId);
|
||||||
if (record.state !== 'running') return { state: 'idle' };
|
if (record.state !== 'running') return { state: 'idle' };
|
||||||
record.state = 'cancelling';
|
record.state = 'cancelling';
|
||||||
// 先让界面看到「正在取消」,再中止引擎与执行
|
// 先让界面看到「正在取消」,再中止引擎与执行
|
||||||
|
|
@ -185,8 +207,8 @@ export class ApplicationService {
|
||||||
heartbeat(sessionId: string): void {
|
heartbeat(sessionId: string): void {
|
||||||
this.sessions.require(sessionId).bus.publish({ sessionId, type: 'heartbeat', data: {} });
|
this.sessions.require(sessionId).bus.publish({ sessionId, type: 'heartbeat', data: {} });
|
||||||
}
|
}
|
||||||
subscribe(sessionId: string, since: number, listener: (event: PlatformEvent) => void): () => void {
|
subscribe(subject: Subject, sessionId: string, since: number, listener: (event: PlatformEvent) => void): () => void {
|
||||||
return this.sessions.require(sessionId).bus.subscribe(since, listener);
|
return this.assertSessionAccess(subject, sessionId).bus.subscribe(since, listener);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastSeq(sessionId: string): number {
|
lastSeq(sessionId: string): number {
|
||||||
|
|
@ -230,17 +252,25 @@ export class ApplicationService {
|
||||||
/** 取(或创建)平台会话对应的运行时会话:工具来自能力目录,历史来自平台记录 */
|
/** 取(或创建)平台会话对应的运行时会话:工具来自能力目录,历史来自平台记录 */
|
||||||
private async runtimeSessionFor(record: SessionRecord): Promise<RuntimeSession> {
|
private async runtimeSessionFor(record: SessionRecord): Promise<RuntimeSession> {
|
||||||
const sessionId = record.summary.sessionId;
|
const sessionId = record.summary.sessionId;
|
||||||
|
const capabilities = this.visibleCapabilitiesFor(record.owner);
|
||||||
|
const fingerprint = `${record.owner.userId}|${capabilityFingerprint(capabilities)}`;
|
||||||
const existing = this.runtimeSessions.get(sessionId);
|
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({
|
const created = await this.runtime.createSession({
|
||||||
sessionId,
|
sessionId,
|
||||||
subject: { userId: record.owner.userId, tenantId: record.owner.tenantId },
|
subject: { userId: record.owner.userId, tenantId: record.owner.tenantId },
|
||||||
projectId: record.summary.projectId,
|
projectId: record.summary.projectId,
|
||||||
system: PLATFORM_SYSTEM_PROMPT,
|
system: PLATFORM_SYSTEM_PROMPT,
|
||||||
tools: toToolDescriptors(this.registry.list()),
|
tools: toToolDescriptors(capabilities),
|
||||||
history: record.messages,
|
history: record.messages,
|
||||||
});
|
});
|
||||||
this.runtimeSessions.set(sessionId, created);
|
this.runtimeSessions.set(sessionId, created);
|
||||||
|
this.runtimeFingerprints.set(sessionId, fingerprint);
|
||||||
return created;
|
return created;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -19,6 +19,15 @@ export interface LlmConfig {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CapabilityConfig {
|
||||||
|
/** 开发期默认 true;接入真实身份后应为 false,由授权表逐条给出 */
|
||||||
|
defaultAllow: boolean;
|
||||||
|
/** 只读模式:屏蔽写能力 */
|
||||||
|
denyWrite: boolean;
|
||||||
|
/** 授权表:谁能看到哪些能力(按 id 前缀) */
|
||||||
|
grants: Array<{ userId?: string; tenantId?: string; allow: string[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EngineConfig {
|
export interface EngineConfig {
|
||||||
/** fake = 假引擎(默认);pi = Pi 子进程(A3.3) */
|
/** fake = 假引擎(默认);pi = Pi 子进程(A3.3) */
|
||||||
kind: 'fake' | 'pi';
|
kind: 'fake' | 'pi';
|
||||||
|
|
@ -27,6 +36,7 @@ export interface EngineConfig {
|
||||||
export interface PlatformConfig {
|
export interface PlatformConfig {
|
||||||
llm: LlmConfig;
|
llm: LlmConfig;
|
||||||
engine: EngineConfig;
|
engine: EngineConfig;
|
||||||
|
capabilities: CapabilityConfig;
|
||||||
database: { path: string };
|
database: { path: string };
|
||||||
server: { host: string; port: number };
|
server: { host: string; port: number };
|
||||||
/** 客户端构建产物目录,可覆盖(默认仓库内 client/app/dist) */
|
/** 客户端构建产物目录,可覆盖(默认仓库内 client/app/dist) */
|
||||||
|
|
@ -54,6 +64,7 @@ export function configFilePath(input: LoadConfigInput = {}): string {
|
||||||
interface FileConfig {
|
interface FileConfig {
|
||||||
llm?: Partial<LlmConfig>;
|
llm?: Partial<LlmConfig>;
|
||||||
engine?: Partial<EngineConfig>;
|
engine?: Partial<EngineConfig>;
|
||||||
|
capabilities?: Partial<CapabilityConfig>;
|
||||||
database?: { path?: string };
|
database?: { path?: string };
|
||||||
server?: { host?: string; port?: number };
|
server?: { host?: string; port?: number };
|
||||||
clientDist?: string;
|
clientDist?: string;
|
||||||
|
|
@ -92,6 +103,12 @@ export function loadConfig(input: LoadConfigInput = {}): PlatformConfig {
|
||||||
return {
|
return {
|
||||||
llm: { provider, baseUrl, model, apiKey },
|
llm: { provider, baseUrl, model, apiKey },
|
||||||
engine,
|
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 },
|
database: { path: dbPath },
|
||||||
server: {
|
server: {
|
||||||
host: env.APS_HOST ?? file.server?.host ?? '127.0.0.1',
|
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> {
|
export function redactConfig(config: PlatformConfig): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
engine: { kind: config.engine.kind },
|
engine: { kind: config.engine.kind },
|
||||||
|
capabilities: { defaultAllow: config.capabilities.defaultAllow, denyWrite: config.capabilities.denyWrite, grants: config.capabilities.grants.length },
|
||||||
llm: {
|
llm: {
|
||||||
provider: config.llm.provider,
|
provider: config.llm.provider,
|
||||||
baseUrl: config.llm.baseUrl,
|
baseUrl: config.llm.baseUrl,
|
||||||
|
|
|
||||||
|
|
@ -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(',');
|
||||||
|
}
|
||||||
|
|
@ -9,11 +9,18 @@ import type { IncomingHttpHeaders } from 'node:http';
|
||||||
import type { Subject } from '../app/subject.js';
|
import type { Subject } from '../app/subject.js';
|
||||||
|
|
||||||
export function resolveSubject(headers: IncomingHttpHeaders): Subject {
|
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 raw = headers['x-aps-client'];
|
||||||
const clientKind = String(Array.isArray(raw) ? raw[0] : (raw ?? '')).toLowerCase() === 'desktop' ? 'desktop' : 'browser';
|
const clientKind = String(Array.isArray(raw) ? raw[0] : (raw ?? '')).toLowerCase() === 'desktop' ? 'desktop' : 'browser';
|
||||||
return {
|
return {
|
||||||
userId: 'dev-user',
|
userId: devUser === '' ? 'dev-user' : devUser,
|
||||||
tenantId: 'dev-tenant',
|
tenantId: devTenant === '' ? 'dev-tenant' : devTenant,
|
||||||
displayName: '开发用户',
|
displayName: '开发用户',
|
||||||
clientKind,
|
clientKind,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@
|
||||||
*/
|
*/
|
||||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
|
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 { ENDPOINT_SPECS, type CreateSessionRequest, type EndpointSpec, type PlatformEvent, type SendMessageRequest } from '@aps/contracts';
|
||||||
|
|
||||||
import { AppError } from '../app/errors.js';
|
import { AppError } from '../app/errors.js';
|
||||||
|
|
@ -87,13 +89,13 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse, service:
|
||||||
case 'POST /v1/sessions':
|
case 'POST /v1/sessions':
|
||||||
return sendJson(res, 200, service.createSession(subject, (await readJson(req)) as CreateSessionRequest));
|
return sendJson(res, 200, service.createSession(subject, (await readJson(req)) as CreateSessionRequest));
|
||||||
case 'GET /v1/sessions/{id}':
|
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':
|
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':
|
case 'POST /v1/sessions/{id}/messages':
|
||||||
return sendJson(res, 202, service.sendMessage(subject, sessionId, (await readJson(req)) as SendMessageRequest));
|
return sendJson(res, 202, service.sendMessage(subject, sessionId, (await readJson(req)) as SendMessageRequest));
|
||||||
case 'POST /v1/sessions/{id}/cancel':
|
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':
|
case 'GET /v1/capabilities':
|
||||||
return sendJson(res, 200, service.capabilities(subject, sessionFilter));
|
return sendJson(res, 200, service.capabilities(subject, sessionFilter));
|
||||||
case 'GET /v1/executions/{executionId}': {
|
case 'GET /v1/executions/{executionId}': {
|
||||||
|
|
@ -122,14 +124,20 @@ function matchRoute(method: string, pathname: string): { route: Route; params: M
|
||||||
return undefined;
|
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 url = new URL(req.url ?? '/', 'http://localhost');
|
||||||
const headerValue = req.headers['last-event-id'];
|
const headerValue = req.headers['last-event-id'];
|
||||||
const raw = url.searchParams.get('since') ?? (Array.isArray(headerValue) ? headerValue[0] : headerValue) ?? '0';
|
const raw = url.searchParams.get('since') ?? (Array.isArray(headerValue) ? headerValue[0] : headerValue) ?? '0';
|
||||||
const parsed = Number.parseInt(String(raw), 10);
|
const parsed = Number.parseInt(String(raw), 10);
|
||||||
const since = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
const since = Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||||
|
|
||||||
service.lastSeq(sessionId); // 会话不存在时先 404
|
service.assertSessionAccess(subject, sessionId); // 会话不存在或不属于当前身份时先拒
|
||||||
|
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'content-type': 'text/event-stream; charset=utf-8',
|
'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'); // 让客户端立刻知道连接已建立
|
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);
|
const heartbeat = setInterval(() => service.heartbeat(sessionId), HEARTBEAT_MS);
|
||||||
heartbeat.unref();
|
heartbeat.unref();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import type { AddressInfo } from 'node:net';
|
||||||
|
|
||||||
import type { AgentRuntime } from './agent/runtime.js';
|
import type { AgentRuntime } from './agent/runtime.js';
|
||||||
import { loadConfig } from './config.js';
|
import { loadConfig } from './config.js';
|
||||||
|
import type { CapabilityVisibility } from './core/registry/visibility.js';
|
||||||
import { openDatabase, type Database } from './core/storage/db.js';
|
import { openDatabase, type Database } from './core/storage/db.js';
|
||||||
import { SessionRepository } from './core/storage/sessions.js';
|
import { SessionRepository } from './core/storage/sessions.js';
|
||||||
import { ApplicationService } from './app/service.js';
|
import { ApplicationService } from './app/service.js';
|
||||||
|
|
@ -28,6 +29,8 @@ export interface StartOptions {
|
||||||
databasePath?: string;
|
databasePath?: string;
|
||||||
/** 引擎覆盖:测试必须传 'fake',避免读到本机的真实模型配置 */
|
/** 引擎覆盖:测试必须传 'fake',避免读到本机的真实模型配置 */
|
||||||
engine?: 'fake' | 'pi';
|
engine?: 'fake' | 'pi';
|
||||||
|
/** 能力可见性覆盖(A3.5):不传则用配置里的规则 */
|
||||||
|
capabilityVisibility?: CapabilityVisibility;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RunningServer {
|
export interface RunningServer {
|
||||||
|
|
@ -52,6 +55,7 @@ export async function startServer(options: StartOptions = {}): Promise<RunningSe
|
||||||
...(options.version === undefined ? {} : { version: options.version }),
|
...(options.version === undefined ? {} : { version: options.version }),
|
||||||
runtime,
|
runtime,
|
||||||
sessionRepository: new SessionRepository(database),
|
sessionRepository: new SessionRepository(database),
|
||||||
|
capabilityVisibility: options.capabilityVisibility ?? config.capabilities,
|
||||||
});
|
});
|
||||||
// 开发期可选:APS_DEV_PROVIDER=1 时注册假 Provider,用于本地联调与演示(默认不注册,能力清单为空)
|
// 开发期可选:APS_DEV_PROVIDER=1 时注册假 Provider,用于本地联调与演示(默认不注册,能力清单为空)
|
||||||
if (process.env.APS_DEV_PROVIDER === '1') {
|
if (process.env.APS_DEV_PROVIDER === '1') {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue