diff --git a/apps/desktop/src/main/agent-access/installer.test.ts b/apps/desktop/src/main/agent-access/installer.test.ts new file mode 100644 index 00000000..27ce3c10 --- /dev/null +++ b/apps/desktop/src/main/agent-access/installer.test.ts @@ -0,0 +1,126 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { AgentAccessInstaller } from './installer' + +function planFixture(target: string, overrides: Partial[0]> = {}) { + return { + target, + url: 'http://127.0.0.1:8790/v1/mcp/agents', + headers: { Authorization: 'Bearer evr_test' }, + mcpConfig: '{}', + skillFiles: [ + { path: 'SKILL.md', content: '---\nname: everroom-agent\n---\nbody' }, + { path: 'references/tools.md', content: '# tools' }, + ], + skillRoot: target === 'claude-code' || target === 'cursor' || target === 'codex' + ? `~/.${target === 'cursor' ? 'cursor' : target === 'codex' ? 'codex' : 'claude'}/skills/everroom-agent/` + : null, + notes: [], + ...overrides, + } +} + +const homes: string[] = [] + +function makeHome(): string { + const home = mkdtempSync(join(tmpdir(), 'agent-access-installer-')) + homes.push(home) + return home +} + +afterEach(() => { + while (homes.length > 0) rmSync(homes.pop()!, { recursive: true, force: true }) +}) + +describe('AgentAccessInstaller', () => { + it('claude-code:写技能目录 + 合并 ~/.claude.json 且保留既有配置', () => { + const home = makeHome() + const claudeJson = join(home, '.claude.json') + writeFileSync(claudeJson, JSON.stringify({ otherSetting: true, mcpServers: { github: { url: 'x' } } }), 'utf8') + const installer = new AgentAccessInstaller(home) + + const outcome = installer.apply(planFixture('claude-code')) + expect(outcome.writtenFiles.some((file) => file.endsWith('SKILL.md'))).toBe(true) + expect(outcome.manualSteps).toHaveLength(0) + + const config = JSON.parse(readFileSync(claudeJson, 'utf8')) as { + otherSetting: boolean + mcpServers: Record + } + expect(config.otherSetting).toBe(true) + expect(config.mcpServers.github).toBeDefined() + expect(config.mcpServers.everroom).toMatchObject({ + type: 'http', + url: 'http://127.0.0.1:8790/v1/mcp/agents', + }) + expect(outcome.backups.length).toBe(1) + }) + + it('cursor:新建 ~/.cursor/mcp.json 并写技能;重复安装幂等', () => { + const home = makeHome() + const installer = new AgentAccessInstaller(home) + installer.apply(planFixture('cursor')) + const outcome = installer.apply(planFixture('cursor')) + + const config = JSON.parse(readFileSync(join(home, '.cursor', 'mcp.json'), 'utf8')) as { + mcpServers: Record + } + expect(config.mcpServers.everroom).toMatchObject({ url: 'http://127.0.0.1:8790/v1/mcp/agents' }) + expect(outcome.backups.length).toBe(1) // 第二次安装时已有文件 → 备份一次 + }) + + it('claude.json 损坏时不写入、返回手动步骤提示', () => { + const home = makeHome() + const claudeJson = join(home, '.claude.json') + writeFileSync(claudeJson, '{broken', 'utf8') + const installer = new AgentAccessInstaller(home) + + const outcome = installer.apply(planFixture('claude-code')) + expect(outcome.notes.some((note) => note.includes('不是有效 JSON'))).toBe(true) + expect(readFileSync(claudeJson, 'utf8')).toBe('{broken') + }) + + it('codex:只写技能目录,TOML 走手动片段且 token 已替换', () => { + const home = makeHome() + const installer = new AgentAccessInstaller(home) + const outcome = installer.apply(planFixture('codex', { + mcpConfig: '[mcp_servers.everroom]\nurl = "http://x"\nhttp_headers = { Authorization = "Bearer " }', + })) + expect(outcome.manualSteps).toHaveLength(1) + expect(outcome.manualSteps[0]!.content).toContain('"Bearer evr_test"') + expect(outcome.manualSteps[0]!.content).not.toContain('') + }) + + it('卸载:移除技能目录与配置键,保留其他服务器与用户配置', () => { + const home = makeHome() + const claudeJson = join(home, '.claude.json') + writeFileSync(claudeJson, JSON.stringify({ + keep: 1, + mcpServers: { github: { url: 'x' }, everroom: { url: 'y' } }, + }), 'utf8') + const installer = new AgentAccessInstaller(home) + installer.apply(planFixture('claude-code')) + + const outcome = installer.uninstall('claude-code') + expect(outcome.removed.some((path) => path.includes('everroom-agent'))).toBe(true) + const config = JSON.parse(readFileSync(claudeJson, 'utf8')) as { + keep: number + mcpServers: Record + } + expect(config.keep).toBe(1) + expect(config.mcpServers.everroom).toBeUndefined() + expect(config.mcpServers.github).toBeDefined() + }) + + it('宿主无技能机制(cherry-studio)时不写任何文件,只给片段', () => { + const home = makeHome() + const installer = new AgentAccessInstaller(home) + const outcome = installer.apply(planFixture('cherry-studio')) + expect(outcome.writtenFiles).toHaveLength(0) + expect(outcome.manualSteps).toHaveLength(1) + expect(outcome.notes.some((note) => note.includes('token'))).toBe(true) + mkdirSync(join(home, 'unused'), { recursive: true }) + }) +}) diff --git a/apps/desktop/src/main/agent-access/installer.ts b/apps/desktop/src/main/agent-access/installer.ts new file mode 100644 index 00000000..a04d4491 --- /dev/null +++ b/apps/desktop/src/main/agent-access/installer.ts @@ -0,0 +1,199 @@ +import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { homedir } from 'node:os' +import type { AgentAccessInstallPlanDto } from '../gateway/agent-access-gateway-bridge' + +/** + * 安装代写落盘(PRD 7.1.2「安装技能与 MCP/CLI 连接」):把网关生成的安装内容 + * 写进目标工具。原则: + * - 只增改 everroom 自己的键/目录,绝不触碰用户其他配置;写入前先备份。 + * - JSON 配置(~/.claude.json、~/.cursor/mcp.json)合并 mcpServers.everroom; + * 解析失败立即中止不写(宁可让用户手动贴片段)。 + * - TOML(~/.codex/config.toml)不做自动合并——返回片段由用户粘贴,避免破坏 + * 手写注释与结构。 + */ + +export interface InstallOutcome { + writtenFiles: string[] + backups: string[] + /** 需要用户手动粘贴的配置片段(标题 + 内容)。 */ + manualSteps: Array<{ title: string; content: string }> + notes: string[] +} + +export interface UninstallOutcome { + removed: string[] + backups: string[] +} + +export class AgentAccessInstaller { + constructor(private readonly homeDirectory: string = homedir()) {} + + private tilde(absolute: string): string { + return absolute.startsWith(this.homeDirectory) + ? absolute.replace(this.homeDirectory, '~') + : absolute + } + + private backupFile(path: string, backups: string[]): void { + if (!existsSync(path)) return + const backupPath = `${path}.everroom-backup-${Date.now()}` + copyFileSync(path, backupPath) + backups.push(this.tilde(backupPath)) + } + + private writeSkillFiles( + plan: AgentAccessInstallPlanDto, + written: string[], + ): string | null { + if (!plan.skillRoot) return null + const root = plan.skillRoot.replace(/^~(?=\/)/, this.homeDirectory) + for (const file of plan.skillFiles) { + const target = join(root, file.path) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, file.content, 'utf8') + written.push(this.tilde(target)) + } + return root + } + + private readJsonObject(path: string): Record | null { + if (!existsSync(path)) return {} + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? parsed as Record + : null + } catch { + return null + } + } + + private mergeMcpServer(input: { + configPath: string + server: Record + written: string[] + backups: string[] + errors: string[] + }): void { + const { configPath, server, written, backups, errors } = input + const config = this.readJsonObject(configPath) + if (config === null) { + errors.push(`${this.tilde(configPath)} 不是有效 JSON,已跳过(请手动粘贴配置片段)`) + return + } + const servers = (config.mcpServers && typeof config.mcpServers === 'object' + ? config.mcpServers as Record + : {}) as Record + servers.everroom = server + config.mcpServers = servers + mkdirSync(dirname(configPath), { recursive: true }) + this.backupFile(configPath, backups) + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8') + written.push(this.tilde(configPath)) + } + + private removeMcpServer(input: { + configPath: string + removed: string[] + backups: string[] + }): void { + const { configPath, removed, backups } = input + const config = this.readJsonObject(configPath) + if (config === null) return + const servers = config.mcpServers as Record | undefined + if (!servers || !('everroom' in servers)) return + delete servers.everroom + this.backupFile(configPath, backups) + writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, 'utf8') + removed.push(this.tilde(configPath)) + } + + private claudeConfig(): string { + return join(this.homeDirectory, '.claude.json') + } + + private cursorConfig(): string { + return join(this.homeDirectory, '.cursor', 'mcp.json') + } + + private serverConfigOf(plan: AgentAccessInstallPlanDto): Record { + // Cursor/通用 JSON 形态:{ url, headers };Claude Code 加 type: http。 + return plan.target === 'claude-code' + ? { type: 'http', url: plan.url, headers: plan.headers } + : { url: plan.url, headers: plan.headers } + } + + apply(plan: AgentAccessInstallPlanDto): InstallOutcome { + const writtenFiles: string[] = [] + const backups: string[] = [] + const manualSteps: Array<{ title: string; content: string }> = [] + const errors: string[] = [] + + const skillRoot = this.writeSkillFiles(plan, writtenFiles) + + if (plan.target === 'claude-code') { + this.mergeMcpServer({ + configPath: this.claudeConfig(), + server: this.serverConfigOf(plan), + written: writtenFiles, + backups, + errors, + }) + } else if (plan.target === 'cursor') { + this.mergeMcpServer({ + configPath: this.cursorConfig(), + server: this.serverConfigOf(plan), + written: writtenFiles, + backups, + errors, + }) + } else if (plan.target === 'codex') { + manualSteps.push({ + title: '把以下内容并入 ~/.codex/config.toml(token 已替换为实际值)', + content: plan.mcpConfig.replace( + /Authorization = "Bearer "/, + `Authorization = ${JSON.stringify(plan.headers.Authorization ?? '')}`, + ), + }) + } else { + manualSteps.push({ title: '按宿主支持的方式添加 MCP 服务器', content: plan.mcpConfig }) + } + + return { + writtenFiles, + backups, + manualSteps, + notes: [ + ...plan.notes, + ...(skillRoot ? [`技能已写入 ${this.tilde(skillRoot)}`] : []), + ...errors, + '配置文件包含连接 token,请勿提交到版本库或分享给他人。', + ], + } + } + + uninstall(target: string): UninstallOutcome { + const removed: string[] = [] + const backups: string[] = [] + + const skillRootsByTarget: Record = { + 'claude-code': join(this.homeDirectory, '.claude', 'skills', 'everroom-agent'), + cursor: join(this.homeDirectory, '.cursor', 'skills', 'everroom-agent'), + codex: join(this.homeDirectory, '.codex', 'skills', 'everroom-agent'), + } + const skillRoot = skillRootsByTarget[target] + if (skillRoot && existsSync(skillRoot)) { + rmSync(skillRoot, { recursive: true, force: true }) + removed.push(this.tilde(skillRoot)) + } + + if (target === 'claude-code') { + this.removeMcpServer({ configPath: this.claudeConfig(), removed, backups }) + } else if (target === 'cursor') { + this.removeMcpServer({ configPath: this.cursorConfig(), removed, backups }) + } + + return { removed, backups } + } +} diff --git a/apps/desktop/src/main/agent-access/settings-store.test.ts b/apps/desktop/src/main/agent-access/settings-store.test.ts new file mode 100644 index 00000000..68c98d71 --- /dev/null +++ b/apps/desktop/src/main/agent-access/settings-store.test.ts @@ -0,0 +1,50 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createServer } from 'node:net' +import { afterEach, describe, expect, it } from 'vitest' +import { AgentAccessSettingsStore, isValidFixedPort } from './settings-store' + +const dirs: string[] = [] + +function makeStore(): AgentAccessSettingsStore { + const dir = mkdtempSync(join(tmpdir(), 'agent-access-settings-')) + dirs.push(dir) + return new AgentAccessSettingsStore(dir) +} + +afterEach(() => { + while (dirs.length > 0) rmSync(dirs.pop()!, { recursive: true, force: true }) +}) + +describe('AgentAccessSettingsStore', () => { + it('默认随机端口;roundtrip 保留合法固定端口', () => { + const store = makeStore() + expect(store.read()).toEqual({ fixedPort: null }) + store.write({ fixedPort: 8790 }) + expect(store.read()).toEqual({ fixedPort: 8790 }) + expect(store.portProvider()).toBe(8790) + }) + + it('非法端口与损坏文件都回退默认,不抛错', () => { + const store = makeStore() + expect(isValidFixedPort(80)).toBe(false) + expect(isValidFixedPort(70_000)).toBe(false) + expect(isValidFixedPort(8790)).toBe(true) + store.write({ fixedPort: 8790 }) + writeFileSync(join(dirs[dirs.length - 1]!, 'agent-access-settings.json'), 'not-json', 'utf8') + expect(store.read()).toEqual({ fixedPort: null }) + }) + + it('isPortFree:空闲端口为真,被占用端口为假', async () => { + const blocker = createServer() + await new Promise((resolve) => blocker.listen(0, '127.0.0.1', resolve)) + const address = blocker.address() as { port: number } + try { + expect(await AgentAccessSettingsStore.isPortFree(address.port)).toBe(false) + expect(await AgentAccessSettingsStore.isPortFree(address.port + 1)).toBe(true) + } finally { + blocker.close() + } + }) +}) diff --git a/apps/desktop/src/main/agent-access/settings-store.ts b/apps/desktop/src/main/agent-access/settings-store.ts new file mode 100644 index 00000000..833bbcd6 --- /dev/null +++ b/apps/desktop/src/main/agent-access/settings-store.ts @@ -0,0 +1,78 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import type { GatewaySupervisor } from '../gateway/gateway-supervisor' + +/** + * 外部 Agent 访问的桌面侧设置(独立于网关库:portProvider 在网关 spawn 前求值, + * 网关还没起,读不了 runtime config)。固定端口让安装到外部工具的 MCP 配置 + * 在网关重启后仍然有效(PRD 7.1 安装一次持续可用)。 + */ + +export interface AgentAccessSettings { + /** null = 随机端口(默认,外部工具配置会随重启失效)。 */ + fixedPort: number | null +} + +const DEFAULTS: AgentAccessSettings = { fixedPort: null } +const PORT_MIN = 1024 +const PORT_MAX = 65_535 + +export function isValidFixedPort(value: number): boolean { + return Number.isInteger(value) && value >= PORT_MIN && value <= PORT_MAX +} + +export class AgentAccessSettingsStore { + private readonly filePath: string + + constructor(private readonly dataDirectory: string) { + this.filePath = join(dataDirectory, 'agent-access-settings.json') + } + + read(): AgentAccessSettings { + try { + if (!existsSync(this.filePath)) return { ...DEFAULTS } + const parsed = JSON.parse(readFileSync(this.filePath, 'utf8')) as Partial + const fixedPort = typeof parsed.fixedPort === 'number' && isValidFixedPort(parsed.fixedPort) + ? parsed.fixedPort + : null + return { fixedPort } + } catch { + // 损坏文件按默认值处理并会被下一次 write 覆盖;不让设置问题阻断网关启动。 + return { ...DEFAULTS } + } + } + + write(next: AgentAccessSettings): void { + mkdirSync(this.dataDirectory, { recursive: true }) + writeFileSync(this.filePath, `${JSON.stringify(next, null, 2)}\n`, 'utf8') + } + + /** supervisor 的 portProvider:同步读文件(每次网关启动求值一次)。 */ + portProvider(): number | null { + return this.read().fixedPort + } + + /** 当前网关实际端口(渲染层展示用;固定端口未生效前与设置值不同)。 */ + currentGatewayPort(supervisor: GatewaySupervisor): number | null { + try { + const baseUrl = supervisor.getConnection().baseUrl + const port = Number(new URL(baseUrl).port) + return Number.isFinite(port) && port > 0 ? port : null + } catch { + return null + } + } + + /** 端口可用性预检:bind 成功即视为空闲(不发送任何数据)。 */ + static async isPortFree(port: number): Promise { + const net = await import('node:net') + return await new Promise((resolve) => { + const probe = net.createServer() + probe.once('error', () => resolve(false)) + probe.once('listening', () => { + probe.close(() => resolve(true)) + }) + probe.listen(port, '127.0.0.1') + }) + } +} diff --git a/apps/desktop/src/main/gateway/agent-access-gateway-bridge.ts b/apps/desktop/src/main/gateway/agent-access-gateway-bridge.ts new file mode 100644 index 00000000..20c9d8bb --- /dev/null +++ b/apps/desktop/src/main/gateway/agent-access-gateway-bridge.ts @@ -0,0 +1,127 @@ +import type { GatewayConnection, GatewaySupervisor } from './gateway-supervisor' +import type { + AgentAccessCreateInput, + AgentAccessExampleQueryDto, + AgentAccessInstallPlanDto, + AgentAccessSelftestDto, + AgentAccessUpdateInput, + AgentConnectionDto, +} from '../../shared/agent-access' + +/** + * 外部 Agent 访问(PRD 0.2.0 第 7 章)的网关桥:连接页 REST(/v1/agent-access/*) + * 的桌面侧出口。模式同 DiaryGatewayBridge(ensureConnection + bearer + 可恢复连接重试)。 + */ + +export type { + AgentAccessCreateInput, + AgentAccessExampleQueryDto, + AgentAccessInstallPlanDto, + AgentAccessSelftestDto, + AgentAccessUpdateInput, + AgentConnectionDto, +} from '../../shared/agent-access' + +const RECOVERABLE_CONNECTION_ERROR_CODES = new Set([ + 'ECONNREFUSED', + 'ECONNRESET', + 'EPIPE', + 'ERR_SOCKET_CLOSED', +]) + +function isRecoverableConnectionError(error: unknown): boolean { + if (!(error instanceof Error)) return false + const cause = error.cause + if (cause && typeof cause === 'object' && 'code' in cause) { + const code = (cause as { code?: unknown }).code + if (typeof code === 'string' && RECOVERABLE_CONNECTION_ERROR_CODES.has(code)) return true + } + return error instanceof TypeError && /fetch failed|network|socket/i.test(error.message) +} + +export class AgentAccessGatewayBridge { + constructor(private readonly supervisor: GatewaySupervisor) {} + + listConnections(): Promise<{ connections: AgentConnectionDto[] }> { + return this.request('/v1/agent-access/connections') + } + + createConnection(input: AgentAccessCreateInput): Promise<{ connection: AgentConnectionDto; token: string }> { + return this.request('/v1/agent-access/connections', { + method: 'POST', + body: JSON.stringify(input), + }) + } + + updateConnection(id: string, patch: AgentAccessUpdateInput): Promise<{ connection: AgentConnectionDto }> { + return this.request(`/v1/agent-access/connections/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: JSON.stringify(patch), + }) + } + + revokeConnection(id: string): Promise<{ connection: AgentConnectionDto }> { + return this.request(`/v1/agent-access/connections/${encodeURIComponent(id)}`, { method: 'DELETE' }) + } + + rotateToken(id: string): Promise<{ connection: AgentConnectionDto; token: string }> { + return this.request(`/v1/agent-access/connections/${encodeURIComponent(id)}/token`, { method: 'POST' }) + } + + submissions(id: string): Promise<{ submissions: Array> }> { + return this.request(`/v1/agent-access/connections/${encodeURIComponent(id)}/submissions`) + } + + events(id: string): Promise<{ events: Array> }> { + return this.request(`/v1/agent-access/connections/${encodeURIComponent(id)}/events`) + } + + selftest(id: string): Promise { + return this.request(`/v1/agent-access/connections/${encodeURIComponent(id)}/selftest`, { method: 'POST' }) + } + + exampleQuery(id: string, query?: string): Promise { + return this.request(`/v1/agent-access/connections/${encodeURIComponent(id)}/example-query`, { + method: 'POST', + body: JSON.stringify(query ? { query } : {}), + }) + } + + install(id: string, target: string, token: string): Promise<{ install: AgentAccessInstallPlanDto }> { + return this.request( + `/v1/agent-access/connections/${encodeURIComponent(id)}/install/${encodeURIComponent(target)}`, + { method: 'POST', body: JSON.stringify({ token }) }, + ) + } + + private async request(path: string, init?: RequestInit): Promise { + const connection = await this.supervisor.ensureConnection() + try { + return await this.requestWithConnection(connection, path, init) + } catch (error) { + if (!isRecoverableConnectionError(error)) throw error + const recovered = await this.supervisor.recoverConnection(connection) + return this.requestWithConnection(recovered, path, init) + } + } + + private async requestWithConnection( + connection: GatewayConnection, + path: string, + init: RequestInit | undefined, + ): Promise { + const response = await fetch(`${connection.baseUrl}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${connection.token}`, + ...(init?.body ? { 'Content-Type': 'application/json' } : {}), + }, + }) + if (!response.ok) { + const body = await response.json().catch(() => null) as { error?: unknown; message?: unknown } | null + throw new Error(typeof body?.message === 'string' ? body.message + : typeof body?.error === 'string' ? body.error : `Agent 连接请求失败(${String(response.status)})`) + } + return response.json() as Promise + } +} diff --git a/apps/desktop/src/main/gateway/gateway-supervisor.ts b/apps/desktop/src/main/gateway/gateway-supervisor.ts index a13a2f3a..04ec3479 100644 --- a/apps/desktop/src/main/gateway/gateway-supervisor.ts +++ b/apps/desktop/src/main/gateway/gateway-supervisor.ts @@ -131,6 +131,12 @@ export class GatewaySupervisor { packagedEntry?: string logLabel?: string devPortEnvironment?: string + /** + * 固定端口(外部 Agent 访问等需要稳定地址的场景):每次 launchGatewayOnce + * 求值,返回 >0 的端口时覆盖 --port(优先级高于 dev 环境变量与 0)。 + * 端口被占用时 gateway 启动失败走既有的重试/报错路径。 + */ + portProvider?: () => number | null /** 连接变化(首次建立、dev 热重载换端口/token、恢复)时回调——依赖 gateway 稳定地址的子进程 env 需要联动刷新。 */ onConnectionChanged?: (connection: GatewayConnection) => void } = {}, @@ -202,13 +208,17 @@ export class GatewaySupervisor { environment.NXCORE_NANGO_SECRET = '' environment.NXCORE_ENV_FILE = join(this.dataDirectory, 'runtime', '.desktop-gateway.env') } + const fixedPort = this.options.portProvider?.() ?? null + const portArgument = fixedPort && fixedPort > 0 + ? String(fixedPort) + : app.isPackaged + ? '0' + : (process.env[this.options.devPortEnvironment ?? 'NXCORE_GATEWAY_DEV_PORT'] ?? '0') const gatewayArguments = [ '--host', '127.0.0.1', '--port', - app.isPackaged - ? '0' - : (process.env[this.options.devPortEnvironment ?? 'NXCORE_GATEWAY_DEV_PORT'] ?? '0'), + portArgument, '--data-dir', this.dataDirectory, '--migrations-dir', diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 21ea16c6..86457957 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -38,6 +38,9 @@ import { AccountKeyringService } from './security/account-keyring-service' import { AgentGatewayBridge } from './gateway/agent-gateway-bridge' import { AsrGatewayBridge } from './gateway/asr-gateway-bridge' import { GatewaySupervisor } from './gateway/gateway-supervisor' +import { AgentAccessSettingsStore, isValidFixedPort } from './agent-access/settings-store' +import { AgentAccessInstaller } from './agent-access/installer' +import { AgentAccessGatewayBridge } from './gateway/agent-access-gateway-bridge' import { cleanupStaleProcessRecords, installExitCleanupHook } from './process-cleanup' import { startDesktopUpdater } from './updater' import { RuntimeConfigBridge, type RuntimeMemoryConfig } from './gateway/runtime-config-bridge' @@ -732,6 +735,25 @@ const AGENT_SCHEDULER_CHANNELS = { runNow: 'agent-scheduler:run-now', } as const +// 外部 Agent 访问(PRD 第 7 章):连接管理/自检/示例查询/安装代写/调试数据 + +// 桌面侧固定端口设置与网关重启(安装到外部工具的配置依赖稳定地址)。 +const AGENT_ACCESS_CHANNELS = { + listConnections: 'agent-access:list-connections', + createConnection: 'agent-access:create-connection', + updateConnection: 'agent-access:update-connection', + revokeConnection: 'agent-access:revoke-connection', + rotateToken: 'agent-access:rotate-token', + submissions: 'agent-access:submissions', + events: 'agent-access:events', + selftest: 'agent-access:selftest', + exampleQuery: 'agent-access:example-query', + install: 'agent-access:install', + uninstall: 'agent-access:uninstall', + settingsGet: 'agent-access:settings-get', + settingsSetFixedPort: 'agent-access:settings-set-fixed-port', + restartGateway: 'agent-access:restart-gateway', +} as const + // 窗口先显示、服务后台初始化:所有 IPC 通道提前挂上路由,处理器注册前先等待就绪。 // IpcHandler 的 never 参数让任意签名的处理器都可直接注册。 type IpcHandler = (event: Electron.IpcMainInvokeEvent, ...args: never[]) => unknown @@ -795,6 +817,7 @@ function installIpcRouters(): void { PERCEPTION_CHANNELS, DIARY_CHANNELS, AGENT_SCHEDULER_CHANNELS, + AGENT_ACCESS_CHANNELS, WRITING_STYLE_CHANNELS, SLIDES_CHANNELS, THESIS_FORMAT_CHANNELS, @@ -843,6 +866,7 @@ function registerBrowserExtensionHandlers(service: BrowserExtensionService): voi let localDataService: LocalDataService | null = null let obsidianVaultService: ObsidianVaultService | null = null let gatewaySupervisor: GatewaySupervisor | null = null +let agentAccessSettingsStore: AgentAccessSettingsStore | null = null /** 系统代理探测结果(app 启动时求值一次,supervisor getter 每次 respawn 读取)。 */ let gatewayProxyEnv: Record = {} /** gateway:recover 的 in-flight 去重(网络失败风暴时并发请求只触发一次恢复)。 */ @@ -879,6 +903,8 @@ let perceptionGatewayBridge: PerceptionGatewayBridge | null = null let diaryGatewayBridge: DiaryGatewayBridge | null = null let writingStyleGatewayBridge: WritingStyleGatewayBridge | null = null let agentSchedulerGatewayBridge: AgentSchedulerGatewayBridge | null = null +let agentAccessGatewayBridge: AgentAccessGatewayBridge | null = null +let agentAccessInstaller: AgentAccessInstaller | null = null let connectorGatewayBridge: ConnectorGatewayBridge | null = null let migrationCoordinator: MigrationCoordinator | null = null let recordingStore: RecordingStore | null = null @@ -3764,6 +3790,82 @@ function registerPerceptionAndDiaryHandlers(): void { }) } +function registerAgentAccessHandlers(): void { + const requireBridge = (): AgentAccessGatewayBridge => { + if (!agentAccessGatewayBridge) throw new Error('Agent 连接服务尚未就绪。') + return agentAccessGatewayBridge + } + const requireString = (value: unknown, label: string): string => { + if (typeof value !== 'string' || !value.trim()) throw new Error(`${label}无效。`) + return value + } + handle(AGENT_ACCESS_CHANNELS.listConnections, () => requireBridge().listConnections()) + handle(AGENT_ACCESS_CHANNELS.createConnection, (_event, input: unknown) => { + if (!input || typeof input !== 'object') throw new Error('Agent 连接参数无效。') + return requireBridge().createConnection(input as never) + }) + handle(AGENT_ACCESS_CHANNELS.updateConnection, (_event, id: unknown, input: unknown) => { + if (typeof id !== 'string' || !input || typeof input !== 'object') throw new Error('Agent 连接参数无效。') + return requireBridge().updateConnection(id, input as never) + }) + handle(AGENT_ACCESS_CHANNELS.revokeConnection, (_event, id: unknown) => + requireBridge().revokeConnection(requireString(id, '连接 ID'))) + handle(AGENT_ACCESS_CHANNELS.rotateToken, (_event, id: unknown) => + requireBridge().rotateToken(requireString(id, '连接 ID'))) + handle(AGENT_ACCESS_CHANNELS.submissions, (_event, id: unknown) => + requireBridge().submissions(requireString(id, '连接 ID'))) + handle(AGENT_ACCESS_CHANNELS.events, (_event, id: unknown) => + requireBridge().events(requireString(id, '连接 ID'))) + handle(AGENT_ACCESS_CHANNELS.selftest, (_event, id: unknown) => + requireBridge().selftest(requireString(id, '连接 ID'))) + handle(AGENT_ACCESS_CHANNELS.exampleQuery, (_event, id: unknown, query: unknown) => { + if (typeof query !== 'string' && query !== undefined) throw new Error('查询参数无效。') + return requireBridge().exampleQuery(requireString(id, '连接 ID'), query) + }) + handle(AGENT_ACCESS_CHANNELS.install, async (_event, connectionId: unknown, target: unknown, token: unknown) => { + const plan = await requireBridge().install( + requireString(connectionId, '连接 ID'), + requireString(target, '目标工具'), + requireString(token, '连接 token'), + ) + if (!agentAccessInstaller) throw new Error('安装服务尚未就绪。') + const outcome = agentAccessInstaller.apply(plan.install) + return { plan: plan.install, outcome } + }) + handle(AGENT_ACCESS_CHANNELS.uninstall, (_event, target: unknown) => { + if (!agentAccessInstaller) throw new Error('安装服务尚未就绪。') + return agentAccessInstaller.uninstall(requireString(target, '目标工具')) + }) + handle(AGENT_ACCESS_CHANNELS.settingsGet, () => { + if (!agentAccessSettingsStore || !gatewaySupervisor) throw new Error('Agent 连接服务尚未就绪。') + return { + fixedPort: agentAccessSettingsStore.read().fixedPort, + currentPort: agentAccessSettingsStore.currentGatewayPort(gatewaySupervisor), + } + }) + handle(AGENT_ACCESS_CHANNELS.settingsSetFixedPort, async (_event, port: unknown) => { + if (!agentAccessSettingsStore) throw new Error('Agent 连接服务尚未就绪。') + if (port === null) { + agentAccessSettingsStore.write({ fixedPort: null }) + return { fixedPort: null as number | null, needsRestart: true } + } + if (typeof port !== 'number' || !isValidFixedPort(port)) { + throw new Error('端口必须是 1024–65535 的整数。') + } + if (!(await AgentAccessSettingsStore.isPortFree(port))) { + throw new Error(`端口 ${port} 已被占用,请换一个。`) + } + agentAccessSettingsStore.write({ fixedPort: port }) + return { fixedPort: port, needsRestart: true } + }) + handle(AGENT_ACCESS_CHANNELS.restartGateway, async () => { + if (!gatewaySupervisor) throw new Error('网关尚未就绪。') + await gatewaySupervisor.shutdown() + const connection = await gatewaySupervisor.start() + return { baseUrl: connection.baseUrl } + }) +} + function isAppWindowNavigationAllowed(url: string): boolean { try { if (process.env.ELECTRON_RENDERER_URL) { @@ -4161,6 +4263,7 @@ if (hasSingleInstanceLock) app.whenReady().then(async () => { officeBridgeServer = null return null }) + agentAccessSettingsStore = new AgentAccessSettingsStore(dataDirectory) gatewaySupervisor = new GatewaySupervisor( dataDirectory, () => ({ @@ -4207,6 +4310,9 @@ if (hasSingleInstanceLock) app.whenReady().then(async () => { : {}), }), { + // 外部 Agent 访问(PRD 第 7 章)固定端口:连接页可设置;设置后安装到 + // 外部工具的 MCP 配置在网关重启后仍有效(随机端口会失效)。 + portProvider: () => agentAccessSettingsStore?.portProvider() ?? null, // gateway 连接变化(dev 热重载换端口/token)时,MemoryCore/KS 等子进程 // 指向 /ai-relay 的稳定 token 随之失效——联动补一次 env 同步。 onConnectionChanged: () => { @@ -4216,6 +4322,12 @@ if (hasSingleInstanceLock) app.whenReady().then(async () => { }, }, ) + // agent-access 桥只包 supervisor(ensureConnection 惰性等待网关就绪),处理器 + // 提前到 start() 前注册:否则早期 IPC 会排队等 servicesReady(全量服务初始化), + // 冷启动即开连接页时列表加载会超时失败(需点「应用」触发二次加载才成功)。 + agentAccessGatewayBridge = new AgentAccessGatewayBridge(gatewaySupervisor) + agentAccessInstaller = new AgentAccessInstaller() + registerAgentAccessHandlers() const gateway = await gatewaySupervisor.start() console.info(`NxCore Gateway ready at ${gateway.baseUrl} (pid=${gateway.pid})`) void screenshotOutbox.flush() diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index b395b365..f5266686 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -3,6 +3,16 @@ import '@sentry/electron/preload' import { contextBridge, ipcRenderer, webUtils } from 'electron' import type { KnowledgeAttachInput, KnowledgeEntityStatus } from '../shared/knowledge' +import type { + AgentAccessCreateInput, + AgentAccessExampleQueryDto, + AgentAccessInstallOutcomeDto, + AgentAccessSelftestDto, + AgentAccessSettingsDto, + AgentAccessUninstallOutcomeDto, + AgentAccessUpdateInput, + AgentConnectionDto, +} from '../shared/agent-access' import type { CreateContextRoomInput, RoomDuplicateCheckInput, @@ -467,6 +477,34 @@ const api: NxcoreDesktopApi = { remove: (id) => invoke('agent-scheduler:remove', id), runNow: (id) => invoke('agent-scheduler:run-now', id), }, + agentAccess: { + listConnections: () => invokeQuietly<{ connections: AgentConnectionDto[] }>('agent-access:list-connections'), + createConnection: (input: AgentAccessCreateInput) => + invoke<{ connection: AgentConnectionDto; token: string }>('agent-access:create-connection', input), + updateConnection: (id: string, input: AgentAccessUpdateInput) => + invoke<{ connection: AgentConnectionDto }>('agent-access:update-connection', id, input), + revokeConnection: (id: string) => invoke<{ connection: AgentConnectionDto }>('agent-access:revoke-connection', id), + rotateToken: (id: string) => invoke<{ connection: AgentConnectionDto; token: string }>('agent-access:rotate-token', id), + submissions: (id: string) => + invokeQuietly<{ submissions: Array> }>('agent-access:submissions', id), + events: (id: string) => + invokeQuietly<{ events: Array> }>('agent-access:events', id), + selftest: (id: string) => invoke('agent-access:selftest', id), + exampleQuery: (id: string, query?: string) => + invoke('agent-access:example-query', id, query), + install: (connectionId: string, target: string, token: string) => + invoke<{ outcome: AgentAccessInstallOutcomeDto; plan: { url: string; notes: string[] } }>( + 'agent-access:install', + connectionId, + target, + token, + ), + uninstall: (target: string) => invoke('agent-access:uninstall', target), + settingsGet: () => invoke('agent-access:settings-get'), + setFixedPort: (port: number | null) => + invoke<{ fixedPort: number | null; needsRestart: boolean }>('agent-access:settings-set-fixed-port', port), + restartGateway: () => invoke<{ baseUrl: string }>('agent-access:restart-gateway'), + }, slides: { /** 配图过目卡轮询:后台逐张生成期间按草稿文档拉最新会话快照。 */ pageImagesStatus: (documentId: string) => diff --git a/apps/desktop/src/renderer/src/components/PageCanvas.tsx b/apps/desktop/src/renderer/src/components/PageCanvas.tsx index d5b6dba1..1f5f318b 100644 --- a/apps/desktop/src/renderer/src/components/PageCanvas.tsx +++ b/apps/desktop/src/renderer/src/components/PageCanvas.tsx @@ -15,6 +15,7 @@ import { SourcesPage } from './pages/SourcesPage' import { WikiPage } from './pages/WikiPage' import { AgentStatusPage } from './pages/AgentStatusPage' import { AgentSchedulesPage } from './pages/AgentSchedulesPage' +import { AgentConnectionsPage } from './pages/AgentConnectionsPage' import { useLocale } from '@/i18n/LocaleContext' const ContextRoomPage = lazy(() => @@ -77,6 +78,7 @@ export function PageCanvas({ ) } if (page === 'schedules') content = + if (page === 'agent-connections') content = if (page === 'rooms') { content = ( }> diff --git a/apps/desktop/src/renderer/src/components/pages/AgentConnectionCard.tsx b/apps/desktop/src/renderer/src/components/pages/AgentConnectionCard.tsx new file mode 100644 index 00000000..1ac50fc3 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/pages/AgentConnectionCard.tsx @@ -0,0 +1,339 @@ +import { useState } from 'react' +import { + CirclePause, + CirclePlay, + ClipboardCopy, + Download, + FlaskConical, + KeyRound, + LoaderCircle, + Search, + ShieldOff, + Wrench, +} from 'lucide-react' +import type { + AgentAccessInstallOutcomeDto, + AgentAccessSelftestDto, + AgentConnectionDto, +} from '../../../../shared/agent-access' +import { useLocale } from '@/i18n/LocaleContext' + +type ExampleResult = { + query: string + result: { status: string; message?: string; data?: { hits?: Array> } } +} + +const STATUS_TONE: Record = { + ok: 'ok', + empty: 'muted', + stale: 'warn', + duplicate: 'warn', + pending_review: 'warn', + no_permission: 'error', + source_lost: 'error', + parse_failed: 'error', + invalid_input: 'error', +} + +function statusTone(status: string): string { + return STATUS_TONE[status] ?? 'muted' +} + +function hitTitle(hit: Record): string { + return typeof hit.title === 'string' && hit.title ? hit.title + : typeof hit.snippet === 'string' ? hit.snippet.slice(0, 60) + : String(hit.objectId ?? '') +} + +export function AgentConnectionCard({ + connection, + locale, + tokenOnce, + onTokenDismissed, + onChanged, + onRemoved, + onActionError, +}: { + connection: AgentConnectionDto + locale: string + tokenOnce: string | null + onTokenDismissed: () => void + onChanged: (connection: AgentConnectionDto) => void + onRemoved: (id: string) => void + onActionError: (message: string) => void +}) { + const { t } = useLocale() + const [busy, setBusy] = useState(null) + const [rotateTokenPanel, setRotateTokenPanel] = useState(null) + const [selftest, setSelftest] = useState(null) + const [example, setExample] = useState(null) + const [install, setInstall] = useState(null) + const [debugOpen, setDebugOpen] = useState(false) + const [debugData, setDebugData] = useState<{ + submissions: Array> + events: Array> + } | null>(null) + const [copied, setCopied] = useState(false) + + const run = async (label: string, action: () => Promise) => { + setBusy(label); onActionError('') + try { + await action() + } catch (err) { + onActionError(err instanceof Error ? err.message : t('surface:agentConnections.actionFailed')) + } finally { + setBusy(null) + } + } + + const setStatus = (status: 'active' | 'paused') => + run('status', async () => { + const next = await window.nxcore!.agentAccess.updateConnection(connection.id, { status }) + onChanged(next.connection) + }) + + const rotateToken = () => + run('rotate', async () => { + const next = await window.nxcore!.agentAccess.rotateToken(connection.id) + onChanged(next.connection) + setRotateTokenPanel(next.token) + }) + + const revoke = () => + run('revoke', async () => { + if (!window.confirm(t('surface:agentConnections.confirmRevoke'))) return + const next = await window.nxcore!.agentAccess.revokeConnection(connection.id) + onChanged(next.connection) + }) + + const runSelftest = () => + run('selftest', async () => { + setExample(null); setInstall(null) + setSelftest(await window.nxcore!.agentAccess.selftest(connection.id)) + }) + + const runExampleQuery = () => + run('example', async () => { + setSelftest(null); setInstall(null) + setExample(await window.nxcore!.agentAccess.exampleQuery(connection.id)) + }) + + const runInstall = () => + run('install', async () => { + // token 只在创建/换发时可见:手头没有就先换发(旧配置随新安装一并覆写)。 + let token = tokenOnce ?? rotateTokenPanel + if (!token) { + const rotated = await window.nxcore!.agentAccess.rotateToken(connection.id) + onChanged(rotated.connection) + token = rotated.token + setRotateTokenPanel(token) + } + const result = await window.nxcore!.agentAccess.install(connection.id, connection.tool, token) + setInstall(result.outcome) + }) + + const runUninstall = () => + run('uninstall', async () => { + await window.nxcore!.agentAccess.uninstall(connection.tool) + setInstall(null) + }) + + const toggleDebug = () => + run('debug', async () => { + if (debugOpen) { + setDebugOpen(false) + return + } + const [submissions, events] = await Promise.all([ + window.nxcore!.agentAccess.submissions(connection.id), + window.nxcore!.agentAccess.events(connection.id), + ]) + setDebugData({ submissions: submissions.submissions, events: events.events }) + setDebugOpen(true) + }) + + const copyToken = async (token: string) => { + try { + // Electron 渲染进程的 navigator.clipboard 因焦点判定经常失败,优先走主进程。 + if (window.nxcore?.clipboard) { + await window.nxcore.clipboard.writeText(token) + } else { + await navigator.clipboard.writeText(token) + } + setCopied(true) + setTimeout(() => setCopied(false), 1_500) + } catch { + onActionError(t('surface:agentConnections.actionFailed')) + } + } + + const roomsSummary = connection.scopes.roomIds === 'all' + ? t('surface:agentConnections.allRooms') + : t('surface:agentConnections.selectedRooms', { count: connection.scopes.roomIds.length }) + const statusLabel = connection.status === 'active' + ? t('surface:agentConnections.statusActive') + : connection.status === 'paused' + ? t('surface:agentConnections.statusPaused') + : t('surface:agentConnections.statusRevoked') + + return
+
+
+

{connection.name}

+ {connection.tool} + {statusLabel} +
+

+ {roomsSummary} · {connection.scopes.capabilities.length} 项能力 + {connection.scopes.writebackRoomId ? ` · ${t('surface:agentConnections.writeback')}: ${connection.scopes.writebackRoomId}` : ''} +

+ + {connection.tokenPrefix}… + {' · '} + {t('surface:agentConnections.lastSeen')}: + {connection.lastSeenAt ? new Date(connection.lastSeenAt).toLocaleString(locale) : t('surface:agentConnections.never')} + +
+ +
+ {connection.status === 'active' ? ( + + ) : connection.status === 'paused' ? ( + + ) : null} + + + + + + +
+ + {tokenOnce !== null || rotateTokenPanel !== null ? ( +
+ {t('surface:agentConnections.tokenOnceTitle')} + {t('surface:agentConnections.tokenOnceHint')} +
+ {tokenOnce ?? rotateTokenPanel} + +
+
+ ) : null} + + {selftest ? ( +
+

{t('surface:agentConnections.selftest')}

+
    + {([['list', 'selftestList'], ['search', 'selftestSearch'], ['fetch', 'selftestFetch'], ['submit', 'selftestSubmit']] as const).map(([key, labelKey]) => { + const step = selftest.steps[key] + return
  • + {t(`surface:agentConnections.${labelKey}`)} + {step.status} + {step.message ? {step.message} : null} +
  • + })} +
+
+ ) : null} + + {example ? ( +
+

{t('surface:agentConnections.exampleQuery')}:{example.query}

+ {example.result.data?.hits?.length ? ( +
    + {example.result.data.hits.slice(0, 5).map((hit, index) => ( +
  • + {String(hit.kind ?? '')} + {hitTitle(hit)} + {typeof hit.uri === 'string' ? {hit.uri} : null} + {typeof hit.version === 'string' && hit.version ? {hit.version} : null} +
  • + ))} +
+ ) : ( + + {example.result.message ?? t('surface:agentConnections.exampleEmpty')} + + )} +
+ ) : null} + + {install ? ( +
+

{t('surface:agentConnections.install')}

+ {install.writtenFiles.length > 0 ? ( +
+ {t('surface:agentConnections.installWritten')} +
    + {install.writtenFiles.map((file) =>
  • {file}
  • )} +
+
+ ) : null} + {install.manualSteps.map((step) => ( +
+ {t('surface:agentConnections.installManual')}:{step.title} +
{step.content}
+
+ ))} + {install.notes.length > 0 ?
    {install.notes.map((note) =>
  • {note}
  • )}
: null} + +
+ ) : null} + + {debugOpen && debugData ? ( +
+

{t('surface:agentConnections.debug')}

+ {t('surface:agentConnections.debugSubmissions')} + {debugData.submissions.length === 0 ? {t('surface:agentConnections.debugEmpty')} : ( +
    + {debugData.submissions.slice(0, 10).map((item, index) => ( +
  • + {String(item.clientSubmissionId ?? '')} + {String(item.status ?? '')} + {String(item.duplicateCount ?? 0)} replays +
  • + ))} +
+ )} + {t('surface:agentConnections.debugEvents')} + {debugData.events.length === 0 ? {t('surface:agentConnections.debugEmpty')} : ( +
    + {debugData.events.slice(0, 10).map((item, index) => ( +
  • + {String(item.kind ?? '')}{item.tool ? ` · ${String(item.tool)}` : ''} + {String(item.status ?? '')} + {item.createdAt ? new Date(String(item.createdAt)).toLocaleString(locale) : ''} +
  • + ))} +
+ )} +
+ ) : null} +
+} diff --git a/apps/desktop/src/renderer/src/components/pages/AgentConnectionCreateForm.tsx b/apps/desktop/src/renderer/src/components/pages/AgentConnectionCreateForm.tsx new file mode 100644 index 00000000..d1f64501 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/pages/AgentConnectionCreateForm.tsx @@ -0,0 +1,171 @@ +import { useEffect, useState } from 'react' +import { LoaderCircle, X } from 'lucide-react' +import type { AgentAccessScopesDto, AgentConnectionDto } from '../../../../shared/agent-access' +import { useLocale } from '@/i18n/LocaleContext' + +export type CreateFormRoomsLoader = () => Promise> + +const TOOLS: Array<{ value: string; label: string }> = [ + { value: 'claude-code', label: 'Claude Code' }, + { value: 'cursor', label: 'Cursor' }, + { value: 'codex', label: 'Codex' }, + { value: 'cherry-studio', label: 'Cherry Studio' }, + { value: 'openclaw', label: 'OpenClaw' }, + { value: 'hermes', label: 'Hermes' }, + { value: 'generic', label: '通用 MCP' }, +] + +const READ_CAPABILITIES = [ + { value: 'read_metadata', label: '发现元信息' }, + { value: 'search', label: '搜索' }, + { value: 'read_conversations', label: '精读会话' }, + { value: 'read_documents', label: '精读文档' }, + { value: 'read_files', label: '读取源文件' }, + { value: 'read_rules', label: '读取规则' }, +] + +const WRITE_CAPABILITIES = [ + { value: 'write_session', label: '回写会话与结果' }, + { value: 'write_candidates', label: '提交记忆/Wiki/偏好候选' }, + { value: 'register_files', label: '登记文件' }, +] + +export function AgentConnectionCreateForm({ + loadRooms, + onCancel, + onCreated, +}: { + loadRooms: CreateFormRoomsLoader + onCancel: () => void + onCreated: (connection: AgentConnectionDto, token: string) => void +}) { + const { t } = useLocale() + const [name, setName] = useState('') + const [tool, setTool] = useState('claude-code') + const [rooms, setRooms] = useState>([]) + const [roomsLoading, setRoomsLoading] = useState(true) + const [allRooms, setAllRooms] = useState(true) + const [selectedRooms, setSelectedRooms] = useState>(new Set()) + const [capabilities, setCapabilities] = useState>(new Set(READ_CAPABILITIES.map((item) => item.value))) + const [writebackRoomId, setWritebackRoomId] = useState('') + const [busy, setBusy] = useState(false) + const [error, setError] = useState(null) + + useEffect(() => { + void loadRooms() + .then((items) => setRooms(items)) + .catch(() => setError(t('surface:agentConnections.roomsLoadFailed'))) + .finally(() => setRoomsLoading(false)) + }, []) + + const toggle = (set: Set, value: string): Set => { + const next = new Set(set) + if (next.has(value)) next.delete(value) + else next.add(value) + return next + } + + const create = async () => { + if (!name.trim() || capabilities.size === 0) return + setBusy(true); setError(null) + const scopes: AgentAccessScopesDto = { + roomIds: allRooms ? 'all' : [...selectedRooms], + capabilities: [...capabilities], + writebackRoomId: writebackRoomId || null, + } + try { + const created = await window.nxcore!.agentAccess.createConnection({ name: name.trim(), tool, scopes }) + onCreated(created.connection, created.token) + } catch (err) { + setError(err instanceof Error ? err.message : t('surface:agentConnections.actionFailed')) + } finally { + setBusy(false) + } + } + + const roomOptions = allRooms ? [] : rooms + + return
+
+

{t('surface:agentConnections.newConnection')}

+ +
+ +
+ + + +
+ {t('surface:agentConnections.rooms')} + + + {!allRooms ? ( + roomsLoading ?
+ +
+ {t('surface:agentConnections.capabilities')} +
+ {[...READ_CAPABILITIES, ...WRITE_CAPABILITIES].map((item) => ( + + ))} +
+
+ + +
+ + {error ?
{error}
: null} + +
+ + +
+
+} diff --git a/apps/desktop/src/renderer/src/components/pages/AgentConnectionsPage.css b/apps/desktop/src/renderer/src/components/pages/AgentConnectionsPage.css new file mode 100644 index 00000000..993e60b4 --- /dev/null +++ b/apps/desktop/src/renderer/src/components/pages/AgentConnectionsPage.css @@ -0,0 +1,429 @@ +/* Agent 连接页:布局与页面特有样式。控件基底复用全局类 + * (.page/.page-header/.primary-button/.secondary-button/.icon-button), + * 表单控件配方与 AgentSchedulesPage / source-connect-form 对齐。 */ + +.agent-connections-page { + display: flex; + flex-direction: column; + gap: 14px; + max-width: 980px; +} + +.agent-connections-subtitle { + display: flex; + align-items: center; + gap: 7px; + margin: -6px 0 4px; + color: var(--muted); + font-size: 12px; +} + +.agent-connections-subtitle svg { + width: 14px; + height: 14px; +} + +.agent-connections-error { + border: 1px solid color-mix(in srgb, #b5493a 35%, var(--border)); + border-radius: 6px; + padding: 11px 14px; + font-size: 12px; + color: #b5493a; +} + +/* 固定端口设置卡 */ +.agent-connections-port { + display: flex; + justify-content: space-between; + gap: 16px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + padding: 14px 18px; +} + +.agent-connections-port-copy h2 { + display: flex; + align-items: center; + gap: 6px; + font-size: 14px; + margin: 0 0 4px; +} + +.agent-connections-port-copy h2 svg { + width: 15px; + height: 15px; +} + +.agent-connections-port-copy p { + margin: 0 0 4px; + color: var(--muted); + font-size: 11px; + max-width: 560px; +} + +.agent-connections-port-copy small { + color: var(--subtle); + font-size: 10px; +} + +.agent-connections-port-controls { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; + max-width: 360px; +} + +.agent-connections-port-controls input { + width: 96px; +} + +.agent-connections-port-message { + flex-basis: 100%; + text-align: right; + color: var(--muted); + font-size: 10px; +} + +/* 新建表单(配方同 schedule-form) */ +.agent-connections-form { + border: 1px solid var(--border-strong); + background: var(--surface); + padding: 18px; +} + +.agent-connections-form-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.agent-connections-form-header h2 { + font-size: 15px; + margin: 0; +} + +.agent-connections-form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.agent-connections-form-grid label { + display: grid; + gap: 6px; + color: var(--muted); + font-size: 11px; +} + +.agent-connections-form-grid fieldset { + display: grid; + gap: 6px; + margin: 0; + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px; + font-size: 11px; + color: var(--muted); +} + +.agent-connections-form-grid legend { + padding: 0 4px; +} + +.agent-connections-form-grid input:not([type='checkbox']):not([type='radio']), +.agent-connections-form-grid select, +.agent-connections-form-grid textarea { + width: 100%; + box-sizing: border-box; + padding: 8px 9px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--background); + color: var(--text); + font: inherit; + font-size: 12px; +} + +.agent-connections-form-grid input[type='checkbox'], +.agent-connections-form-grid input[type='radio'] { + accent-color: var(--accent); + margin: 0; +} + +.agent-connections-form-wide { + grid-column: 1 / -1; +} + +.agent-connections-check { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text); +} + +.agent-connections-room-picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 4px; + max-height: 148px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 6px; + padding: 6px 8px; +} + +.agent-connections-capabilities { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + gap: 4px; +} + +.agent-connections-form-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 16px; +} + +/* 连接卡片(版式同 schedule-card) */ +.agent-connections-list { + display: grid; + gap: 10px; +} + +.agent-connections-card { + display: flex; + flex-direction: column; + gap: 8px; + border: 1px solid var(--border); + background: var(--surface); + padding: 16px 18px; +} + +.agent-connections-card[data-status='revoked'] { + opacity: 0.6; +} + +.agent-connections-card-copy { + display: flex; + justify-content: space-between; + gap: 12px; +} + +.agent-connections-card-title { + display: flex; + align-items: center; + gap: 8px; +} + +.agent-connections-card-title h2 { + font-size: 14px; + margin: 0; +} + +.agent-connections-card-copy p { + margin: 7px 0 5px; + color: var(--muted); + font-size: 11px; +} + +.agent-connections-card-copy small { + color: var(--subtle); + font-size: 10px; +} + +.agent-connections-card-copy code { + padding: 2px 6px; + border: 1px solid var(--border); + color: var(--muted); + font: 10px ui-monospace, monospace; +} + +/* 徽标/状态(配方同 schedule-badge) */ +.agent-connections-badge { + padding: 2px 6px; + border: 1px solid var(--border); + color: var(--muted); + font-size: 10px; +} + +.agent-connections-status { + padding: 2px 6px; + border: 1px solid var(--border); + font-size: 10px; +} + +.agent-connections-status.status-active { + border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); + color: var(--accent); +} + +.agent-connections-status.status-paused { + color: var(--muted); +} + +.agent-connections-status.status-revoked { + color: var(--subtle); +} + +.agent-connections-card-actions { + display: flex; + align-items: center; + gap: 6px; + justify-content: flex-end; + flex-wrap: wrap; +} + +/* 动作按钮:复用 .secondary-button 基底,仅做紧凑修饰 */ +.agent-connections-action { + min-height: 28px; + padding: 4px 10px; + gap: 5px; + font-size: 11px; +} + +.agent-connections-action svg { + width: 13px; + height: 13px; +} + +.agent-connections-action.danger { + color: #b5493a; +} + +/* token 一次性展示 */ +.agent-connections-token { + display: flex; + flex-direction: column; + gap: 4px; + border: 1px solid var(--border-strong); + border-radius: 6px; + padding: 8px 10px; + background: var(--background); +} + +.agent-connections-token strong { + font-size: 12px; +} + +.agent-connections-token small { + color: var(--muted); + font-size: 10px; +} + +.agent-connections-token-row { + display: flex; + align-items: center; + gap: 8px; +} + +.agent-connections-token-row code { + flex: 1; + word-break: break-all; + font-size: 11px; +} + +/* 结果面板(自检/示例/安装/调试) */ +.agent-connections-panel { + display: flex; + flex-direction: column; + gap: 6px; + border-top: 1px dashed var(--border); + padding-top: 8px; +} + +.agent-connections-panel h3 { + font-size: 12px; + margin: 0; + color: var(--muted); +} + +.agent-connections-steps, +.agent-connections-hits, +.agent-connections-files, +.agent-connections-notes { + margin: 0; + padding: 0; + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; +} + +.agent-connections-steps li { + display: flex; + align-items: baseline; + gap: 8px; +} + +.agent-connections-steps li[data-tone='ok'] code { color: #1c7d4d; } +.agent-connections-steps li[data-tone='warn'] code { color: #9a6b15; } +.agent-connections-steps li[data-tone='error'] code { color: #b5493a; } + +.agent-connections-hits li { + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} + +.agent-connections-hit-kind { + padding: 0 5px; + border: 1px solid var(--border); + font-size: 10px; + color: var(--muted); +} + +.agent-connections-hits code, +.agent-connections-files code { + font-size: 11px; + color: var(--muted); +} + +.agent-connections-panel pre { + margin: 0; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--background); + overflow-x: auto; + font-size: 11px; +} + +.agent-connections-notes { + color: var(--subtle); + font-size: 10px; +} + +.agent-connections-empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 150px; + color: var(--muted); + font-size: 12px; + border: 1px dashed var(--border); +} + +@media (max-width: 760px) { + .agent-connections-port, + .agent-connections-card-copy { + flex-direction: column; + } + + .agent-connections-form-grid { + grid-template-columns: 1fr; + } + + .agent-connections-form-wide { + grid-column: auto; + } +} diff --git a/apps/desktop/src/renderer/src/components/pages/AgentConnectionsPage.tsx b/apps/desktop/src/renderer/src/components/pages/AgentConnectionsPage.tsx new file mode 100644 index 00000000..c398859b --- /dev/null +++ b/apps/desktop/src/renderer/src/components/pages/AgentConnectionsPage.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react' +import { LoaderCircle, PlugZap, RefreshCw, Settings2 } from 'lucide-react' +import type { AgentAccessCreateInput, AgentAccessSettingsDto, AgentConnectionDto } from '../../../../shared/agent-access' +import { useLocale } from '@/i18n/LocaleContext' +import { AgentConnectionCard } from './AgentConnectionCard' +import { AgentConnectionCreateForm, type CreateFormRoomsLoader } from './AgentConnectionCreateForm' +import { PageHeader } from './PageHeader' +import './AgentConnectionsPage.css' + +const LOAD_TIMEOUT_MS = 15_000 + +/** IPC 卡死兜底:任何调用超过 15s 转为可读错误,不无限转圈。 */ +function withTimeout(promise: Promise, timeoutMs = LOAD_TIMEOUT_MS): Promise { + return Promise.race([ + promise, + new Promise((_, reject) => { + setTimeout(() => reject(new Error('LOAD_TIMEOUT')), timeoutMs) + }), + ]) +} + +export function AgentConnectionsPage() { + const { t, locale } = useLocale() + const [connections, setConnections] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [creating, setCreating] = useState(false) + const [tokenPanel, setTokenPanel] = useState<{ connectionId: string; token: string } | null>(null) + const [settings, setSettings] = useState(null) + const [portDraft, setPortDraft] = useState('') + const [portBusy, setPortBusy] = useState(false) + const [portMessage, setPortMessage] = useState(null) + + const load = async () => { + setLoading(true); setError(null) + try { + const [list, settingsSnapshot] = await Promise.all([ + withTimeout(window.nxcore!.agentAccess.listConnections()), + withTimeout(window.nxcore!.agentAccess.settingsGet()), + ]) + setConnections(list.connections) + setSettings(settingsSnapshot) + setPortDraft(settingsSnapshot.fixedPort === null ? '' : String(settingsSnapshot.fixedPort)) + } catch (err) { + setError(err instanceof Error && err.message === 'LOAD_TIMEOUT' + ? t('surface:agentConnections.loadTimeout') + : err instanceof Error ? err.message : t('surface:agentConnections.loadFailed')) + } finally { + setLoading(false) + } + } + + const loadRooms: CreateFormRoomsLoader = async () => { + const response = await window.nxcore!.knowledge.listRooms() + return response.items.map((room) => ({ id: room.id, title: room.title })) + } + + const applyPort = async (port: number | null) => { + setPortBusy(true); setPortMessage(null) + try { + await window.nxcore!.agentAccess.setFixedPort(port) + setPortMessage(t('surface:agentConnections.portNeedsRestart')) + await load() + } catch (err) { + setError(err instanceof Error ? err.message : t('surface:agentConnections.actionFailed')) + } finally { + setPortBusy(false) + } + } + + const restartGateway = async () => { + setPortBusy(true); setPortMessage(null) + try { + await window.nxcore!.agentAccess.restartGateway() + await load() + } catch (err) { + setError(err instanceof Error ? err.message : t('surface:agentConnections.actionFailed')) + } finally { + setPortBusy(false) + } + } + + const onCreated = (connection: AgentConnectionDto, token: string) => { + setConnections((items) => [connection, ...items]) + setCreating(false) + setTokenPanel({ connectionId: connection.id, token }) + } + + return
+ void load()} + disabled={loading} + > +
+} diff --git a/apps/desktop/src/renderer/src/data/navigation.test.ts b/apps/desktop/src/renderer/src/data/navigation.test.ts index 4f2b09e3..e755b9f2 100644 --- a/apps/desktop/src/renderer/src/data/navigation.test.ts +++ b/apps/desktop/src/renderer/src/data/navigation.test.ts @@ -7,7 +7,7 @@ describe('desktop navigation', () => { const execution = navigationSections.find((section) => section.id === 'execution') const pageIds = navigationSections.flatMap((section) => section.items.map((item) => item.id)) - expect(execution?.items.map((item) => item.id)).toEqual(['office', 'diary', 'schedules']) + expect(execution?.items.map((item) => item.id)).toEqual(['office', 'diary', 'schedules', 'agent-connections']) expect(pageIds).not.toContain('agents') expect(pageIds).not.toContain('tasks') expect(pageIds).not.toContain('office-test') diff --git a/apps/desktop/src/renderer/src/data/navigation.ts b/apps/desktop/src/renderer/src/data/navigation.ts index b940643a..140802ae 100644 --- a/apps/desktop/src/renderer/src/data/navigation.ts +++ b/apps/desktop/src/renderer/src/data/navigation.ts @@ -11,11 +11,12 @@ import { AudioLines, NotebookPen, ListTodo, + PlugZap, Settings, FilePenLine, } from 'lucide-react' -export type PageId = 'home' | 'office' | 'office-document' | 'rooms' | 'docs' | 'recording' | 'sources' | 'files' | 'inspiration' | 'memory' | 'wiki' | 'diary' | 'schedules' | 'settings' +export type PageId = 'home' | 'office' | 'office-document' | 'rooms' | 'docs' | 'recording' | 'sources' | 'files' | 'inspiration' | 'memory' | 'wiki' | 'diary' | 'schedules' | 'agent-connections' | 'settings' export interface NavigationItem { id: PageId @@ -58,6 +59,7 @@ export const navigationSections: NavigationSection[] = [ { id: 'office', label: 'surface:navigation.office', icon: Building2, tone: 'blue' }, { id: 'diary', label: 'surface:navigation.diary', icon: NotebookPen, tone: 'blue' }, { id: 'schedules', label: 'surface:navigation.schedules', icon: ListTodo, tone: 'orange' }, + { id: 'agent-connections', label: 'surface:navigation.agentConnections', icon: PlugZap, tone: 'indigo' }, ], }, { diff --git a/apps/desktop/src/renderer/src/i18n/locales/en-US/surface.json b/apps/desktop/src/renderer/src/i18n/locales/en-US/surface.json index f8eca2ab..4970e0f1 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/en-US/surface.json +++ b/apps/desktop/src/renderer/src/i18n/locales/en-US/surface.json @@ -1528,7 +1528,66 @@ "configGate.embeddingIncomplete": "Embedding is partially filled: model name, Base URL, and API key must all be set — or left entirely empty.", "configGate.embeddingTestLabel": "Embedding test failed: ", "navigation.schedules": "Schedules", + "navigation.agentConnections": "Agent connections", "schedules.title": "Agent schedules", + "agentConnections.title": "Agent connections", + "agentConnections.subtitle": "Bring EverRoom's knowledge into your AI tools: search, read and write back", + "agentConnections.refresh": "Refresh", + "agentConnections.newConnection": "New connection", + "agentConnections.empty": "No external agent connections yet", + "agentConnections.emptyHint": "Create a connection, then install the generated skill and MCP config into your target tool.", + "agentConnections.name": "Connection name", + "agentConnections.tool": "Target tool", + "agentConnections.rooms": "Accessible rooms", + "agentConnections.allRooms": "All rooms", + "agentConnections.selectedRooms": "{count} selected", + "agentConnections.capabilities": "Allowed capabilities", + "agentConnections.writeback": "Writeback location", + "agentConnections.writebackNone": "No writeback", + "agentConnections.create": "Create connection", + "agentConnections.cancel": "Cancel", + "agentConnections.save": "Save", + "agentConnections.statusActive": "Active", + "agentConnections.statusPaused": "Paused", + "agentConnections.statusRevoked": "Revoked", + "agentConnections.lastSeen": "Last seen", + "agentConnections.never": "Never used", + "agentConnections.pause": "Pause", + "agentConnections.resume": "Resume", + "agentConnections.rotate": "Rotate token", + "agentConnections.revoke": "Revoke", + "agentConnections.confirmRevoke": "Tools using this connection will stop working immediately and this cannot be undone. Revoke?", + "agentConnections.selftest": "Self-test", + "agentConnections.exampleQuery": "Example query", + "agentConnections.install": "Install", + "agentConnections.uninstall": "Uninstall", + "agentConnections.debug": "Debug", + "agentConnections.tokenOnceTitle": "Connection token (shown only once)", + "agentConnections.tokenOnceHint": "Copy it now; the install step needs it. If lost, rotate the token.", + "agentConnections.copy": "Copy", + "agentConnections.copied": "Copied", + "agentConnections.selftestList": "Discover", + "agentConnections.selftestSearch": "Search", + "agentConnections.selftestFetch": "Deep read", + "agentConnections.selftestSubmit": "Writeback", + "agentConnections.exampleEmpty": "No results", + "agentConnections.installWritten": "Written", + "agentConnections.installManual": "Manual step", + "agentConnections.debugSubmissions": "Submissions", + "agentConnections.debugEvents": "Recent calls", + "agentConnections.debugEmpty": "Nothing yet", + "agentConnections.portTitle": "Fixed port", + "agentConnections.portDesc": "MCP configs installed into external tools point at the gateway address. The default random port breaks after a gateway restart; a fixed port keeps installs working.", + "agentConnections.portCurrent": "Current port", + "agentConnections.portRandom": "Random", + "agentConnections.portApply": "Apply", + "agentConnections.portDisable": "Back to random", + "agentConnections.portNeedsRestart": "Saved. Restart the gateway to apply.", + "agentConnections.restartGateway": "Restart gateway", + "agentConnections.restarting": "Restarting…", + "agentConnections.loadFailed": "Failed to load agent connections", + "agentConnections.actionFailed": "Action failed", + "agentConnections.roomsLoadFailed": "Failed to load rooms", "schedules.newTask": "New task", "schedules.refresh": "Refresh tasks", "schedules.loadFailed": "Unable to load schedules", @@ -1850,5 +1909,6 @@ "screenPermission.devRestartHint": "Dev build: restart the app from your terminal", "screenPermission.close": "Close", "files.categoryImage": "Images", - "topBar.openSearch": "Search (⌘K)" -} \ No newline at end of file + "topBar.openSearch": "Search (⌘K)", + "agentConnections.loadTimeout": "Load timed out: gateway not responding. Use the refresh button; if it persists, restart the app." +} diff --git a/apps/desktop/src/renderer/src/i18n/locales/zh-CN/surface.json b/apps/desktop/src/renderer/src/i18n/locales/zh-CN/surface.json index 2aae3903..9886784a 100644 --- a/apps/desktop/src/renderer/src/i18n/locales/zh-CN/surface.json +++ b/apps/desktop/src/renderer/src/i18n/locales/zh-CN/surface.json @@ -1116,6 +1116,7 @@ "navigation.system": "系统", "navigation.diary": "日记", "navigation.schedules": "定时任务", + "navigation.agentConnections": "Agent 连接", "navigation.settings": "设置", "themeSwitcher.softDescription": "全拟态黑白灰", "themeSwitcher.monoDescription": "中性黑白灰", @@ -1529,6 +1530,64 @@ "configGate.embeddingIncomplete": "Embedding 已填写:模型名称、Base URL 和 API Key 需要填全,或全部留空。", "configGate.embeddingTestLabel": "Embedding 测试未通过:", "schedules.title": "Agent 定时任务", + "agentConnections.title": "Agent 连接", + "agentConnections.subtitle": "把 EverRoom 的积累接入你的 AI 工具:搜索、精读与回写", + "agentConnections.refresh": "刷新", + "agentConnections.newConnection": "新建连接", + "agentConnections.empty": "还没有外部 Agent 连接", + "agentConnections.emptyHint": "创建连接后,把生成的 Skill 和 MCP 配置安装到目标工具即可开始使用。", + "agentConnections.name": "连接名称", + "agentConnections.tool": "目标工具", + "agentConnections.rooms": "可访问的 Room", + "agentConnections.allRooms": "全部 Room", + "agentConnections.selectedRooms": "已选 {count} 个", + "agentConnections.capabilities": "允许的能力", + "agentConnections.writeback": "回写位置", + "agentConnections.writebackNone": "不回写", + "agentConnections.create": "创建连接", + "agentConnections.cancel": "取消", + "agentConnections.save": "保存", + "agentConnections.statusActive": "使用中", + "agentConnections.statusPaused": "已暂停", + "agentConnections.statusRevoked": "已吊销", + "agentConnections.lastSeen": "最近调用", + "agentConnections.never": "从未调用", + "agentConnections.pause": "暂停", + "agentConnections.resume": "恢复", + "agentConnections.rotate": "换 token", + "agentConnections.revoke": "吊销", + "agentConnections.confirmRevoke": "吊销后使用此连接的外部工具将立即失效,且不可恢复。确定吊销?", + "agentConnections.selftest": "自检", + "agentConnections.exampleQuery": "示例查询", + "agentConnections.install": "安装", + "agentConnections.uninstall": "卸载安装", + "agentConnections.debug": "调试", + "agentConnections.tokenOnceTitle": "连接 token(仅显示这一次)", + "agentConnections.tokenOnceHint": "请立即复制;安装配置时需要它。丢失后只能换发新 token。", + "agentConnections.copy": "复制", + "agentConnections.copied": "已复制", + "agentConnections.selftestList": "发现", + "agentConnections.selftestSearch": "搜索", + "agentConnections.selftestFetch": "精读", + "agentConnections.selftestSubmit": "回写", + "agentConnections.exampleEmpty": "没有命中结果", + "agentConnections.installWritten": "已写入", + "agentConnections.installManual": "需要手动粘贴", + "agentConnections.debugSubmissions": "回写提交", + "agentConnections.debugEvents": "最近调用", + "agentConnections.debugEmpty": "暂无记录", + "agentConnections.portTitle": "固定端口", + "agentConnections.portDesc": "外部工具的 MCP 配置依赖网关地址。默认端口随机,网关重启后配置会失效;设置固定端口后一次安装持续有效。", + "agentConnections.portCurrent": "当前端口", + "agentConnections.portRandom": "随机", + "agentConnections.portApply": "应用", + "agentConnections.portDisable": "改回随机", + "agentConnections.portNeedsRestart": "设置已保存,重启网关后生效。", + "agentConnections.restartGateway": "重启网关", + "agentConnections.restarting": "正在重启…", + "agentConnections.loadFailed": "Agent 连接加载失败", + "agentConnections.actionFailed": "操作失败", + "agentConnections.roomsLoadFailed": "Room 列表加载失败", "schedules.newTask": "新建任务", "schedules.refresh": "刷新任务", "schedules.loadFailed": "无法加载定时任务", @@ -1850,5 +1909,6 @@ "screenPermission.devRestartHint": "开发模式:请在终端手动重启应用", "screenPermission.close": "关闭", "files.categoryImage": "图片", - "topBar.openSearch": "搜索(⌘K)" -} \ No newline at end of file + "topBar.openSearch": "搜索(⌘K)", + "agentConnections.loadTimeout": "加载超时:网关未响应,请点右上角刷新重试;若持续超时请重启应用。" +} diff --git a/apps/desktop/src/shared/agent-access.ts b/apps/desktop/src/shared/agent-access.ts new file mode 100644 index 00000000..821f9641 --- /dev/null +++ b/apps/desktop/src/shared/agent-access.ts @@ -0,0 +1,83 @@ +/** + * 外部 Agent 访问(PRD 0.2.0 第 7 章)的桌面共享类型: + * 主进程桥(agent-access-gateway-bridge)、preload 与渲染层连接页共用。 + * 与网关 /v1/agent-access/* 的 DTO 形状保持一致(apps/gateway/src/modules/agent-access)。 + */ + +export interface AgentAccessScopesDto { + roomIds: string[] | 'all' + capabilities: string[] + writebackRoomId: string | null +} + +export interface AgentConnectionDto { + id: string + name: string + tool: string + status: 'active' | 'paused' | 'revoked' + scopes: AgentAccessScopesDto + tokenPrefix: string + createdAt: string + updatedAt: string + lastSeenAt: string | null + revokedAt: string | null + exampleQueryAt: string | null +} + +export interface AgentAccessCreateInput { + name: string + tool: string + scopes: AgentAccessScopesDto +} + +export interface AgentAccessUpdateInput { + name?: string + scopes?: AgentAccessScopesDto + status?: 'active' | 'paused' +} + +export interface AgentAccessInstallPlanDto { + target: string + url: string + headers: Record + mcpConfig: string + skillFiles: Array<{ path: string; content: string }> + skillRoot: string | null + notes: string[] +} + +export interface AgentAccessInstallOutcomeDto { + writtenFiles: string[] + backups: string[] + manualSteps: Array<{ title: string; content: string }> + notes: string[] +} + +export interface AgentAccessUninstallOutcomeDto { + removed: string[] + backups: string[] +} + +export interface AgentAccessSelftestDto { + steps: { + list: { status: string; message?: string } + search: { status: string; message?: string; data?: { hits?: Array> } } + fetch: { status: string; message?: string } + submit: { status: string; message?: string } + } + query: string +} + +export interface AgentAccessExampleQueryDto { + query: string + result: { + status: string + message?: string + data?: { hits?: Array> } + } +} + +export interface AgentAccessSettingsDto { + fixedPort: number | null + currentPort: number | null +} diff --git a/apps/desktop/src/shared/sources.ts b/apps/desktop/src/shared/sources.ts index 5b258317..4ffcaa3e 100644 --- a/apps/desktop/src/shared/sources.ts +++ b/apps/desktop/src/shared/sources.ts @@ -1196,6 +1196,22 @@ export interface NxcoreDesktopApi { remove(id: string): Promise runNow(id: string): Promise<{ runId: string }> } + agentAccess: { + listConnections(): Promise<{ connections: import('./agent-access').AgentConnectionDto[] }> + createConnection(input: import('./agent-access').AgentAccessCreateInput): Promise<{ connection: import('./agent-access').AgentConnectionDto; token: string }> + updateConnection(id: string, input: import('./agent-access').AgentAccessUpdateInput): Promise<{ connection: import('./agent-access').AgentConnectionDto }> + revokeConnection(id: string): Promise<{ connection: import('./agent-access').AgentConnectionDto }> + rotateToken(id: string): Promise<{ connection: import('./agent-access').AgentConnectionDto; token: string }> + submissions(id: string): Promise<{ submissions: Array> }> + events(id: string): Promise<{ events: Array> }> + selftest(id: string): Promise + exampleQuery(id: string, query?: string): Promise + install(connectionId: string, target: string, token: string): Promise<{ outcome: import('./agent-access').AgentAccessInstallOutcomeDto; plan: { url: string; notes: string[] } }> + uninstall(target: string): Promise + settingsGet(): Promise + setFixedPort(port: number | null): Promise<{ fixedPort: number | null; needsRestart: boolean }> + restartGateway(): Promise<{ baseUrl: string }> + } slides: { pageImagesStatus(documentId: string): Promise } diff --git a/apps/gateway/drizzle/0081_agent_access.sql b/apps/gateway/drizzle/0081_agent_access.sql new file mode 100644 index 00000000..3fa715ba --- /dev/null +++ b/apps/gateway/drizzle/0081_agent_access.sql @@ -0,0 +1,50 @@ +CREATE TABLE `agent_connections` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `tool` text NOT NULL, + `token_hash` text NOT NULL, + `token_prefix` text NOT NULL, + `scopes` text NOT NULL, + `status` text DEFAULT 'active' NOT NULL, + `last_seen_at` integer, + `example_query_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `revoked_at` integer +); +--> statement-breakpoint +CREATE UNIQUE INDEX `agent_connections_token_hash_idx` ON `agent_connections` (`token_hash`); +--> statement-breakpoint +CREATE INDEX `agent_connections_status_idx` ON `agent_connections` (`status`); +--> statement-breakpoint +CREATE TABLE `agent_submissions` ( + `id` text PRIMARY KEY NOT NULL, + `connection_id` text NOT NULL, + `client_submission_id` text NOT NULL, + `items` text NOT NULL, + `status` text DEFAULT 'received' NOT NULL, + `results` text, + `duplicate_count` integer DEFAULT 0 NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `completed_at` integer, + FOREIGN KEY (`connection_id`) REFERENCES `agent_connections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE UNIQUE INDEX `agent_submissions_connection_client_idx` ON `agent_submissions` (`connection_id`,`client_submission_id`); +--> statement-breakpoint +CREATE INDEX `agent_submissions_recent_idx` ON `agent_submissions` (`connection_id`,`created_at`); +--> statement-breakpoint +CREATE TABLE `agent_access_events` ( + `id` text PRIMARY KEY NOT NULL, + `connection_id` text NOT NULL, + `kind` text NOT NULL, + `tool` text, + `status` text NOT NULL, + `duration_ms` integer, + `detail` text, + `created_at` integer NOT NULL, + FOREIGN KEY (`connection_id`) REFERENCES `agent_connections`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `agent_access_events_recent_idx` ON `agent_access_events` (`connection_id`,`created_at`); diff --git a/apps/gateway/drizzle/meta/_journal.json b/apps/gateway/drizzle/meta/_journal.json index d60d04f2..d1fe763b 100644 --- a/apps/gateway/drizzle/meta/_journal.json +++ b/apps/gateway/drizzle/meta/_journal.json @@ -554,6 +554,13 @@ "when": 1791980400000, "tag": "0080_thesis_format_cards_multi", "breakpoints": true + }, + { + "idx": 82, + "version": "6", + "when": 1792210966000, + "tag": "0081_agent_access", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/gateway/src/infrastructure/database/schema.ts b/apps/gateway/src/infrastructure/database/schema.ts index f2773b54..793b3053 100644 --- a/apps/gateway/src/infrastructure/database/schema.ts +++ b/apps/gateway/src/infrastructure/database/schema.ts @@ -3128,3 +3128,90 @@ export const searchIndexRooms = sqliteTable( index("search_index_rooms_room_idx").on(table.roomId), ], ); + +/** + * 外部 Agent 连接(PRD 0.2.0 第 7 章 L2.5):Agent 连接页签发的持久凭证。 + * token 只存 sha256(tokenHash),创建/轮换时明文返回一次;status=paused + * 拒绝校验但保留,revoked 终结。scopes 为能力 + Room 范围 + 回写位置的快照。 + */ +export const agentConnections = sqliteTable( + "agent_connections", + { + id: text("id").primaryKey(), + name: text("name").notNull(), + tool: text("tool", { + enum: ["claude-code", "cursor", "codex", "cherry-studio", "openclaw", "hermes", "generic"], + }).notNull(), + tokenHash: text("token_hash").notNull(), + tokenPrefix: text("token_prefix").notNull(), + scopes: text("scopes", { mode: "json" }).$type<{ + roomIds: string[] | "all"; + capabilities: string[]; + writebackRoomId: string | null; + }>().notNull(), + status: text("status", { enum: ["active", "paused", "revoked"] }).notNull().default("active"), + lastSeenAt: integer("last_seen_at", { mode: "timestamp_ms" }), + exampleQueryAt: integer("example_query_at", { mode: "timestamp_ms" }), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .notNull() + .$defaultFn(() => new Date()), + revokedAt: integer("revoked_at", { mode: "timestamp_ms" }), + }, + (table) => [ + uniqueIndex("agent_connections_token_hash_idx").on(table.tokenHash), + index("agent_connections_status_idx").on(table.status), + ], +); + +/** + * 外部 Agent 幂等提交(PRD 7.4 / A.3 提交键):会话增量、结果文本、产物文件、 + * 来源引用与候选统一落此表,(connectionId, clientSubmissionId) 唯一去重, + * 重试重放返回已存结果(duplicate)。items 保留原始载荷(含两链合并键: + * 平台/线程/消息 ID + 内容),results 为逐项处理结果。 + */ +export const agentSubmissions = sqliteTable( + "agent_submissions", + { + id: text("id").primaryKey(), + connectionId: text("connection_id").notNull().references(() => agentConnections.id, { onDelete: "cascade" }), + clientSubmissionId: text("client_submission_id").notNull(), + items: text("items", { mode: "json" }).$type().notNull(), + status: text("status", { + enum: ["received", "completed", "partial_failed", "rejected"], + }).notNull().default("received"), + results: text("results", { mode: "json" }).$type(), + duplicateCount: integer("duplicate_count").notNull().default(0), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .notNull() + .$defaultFn(() => new Date()), + updatedAt: integer("updated_at", { mode: "timestamp_ms" }) + .notNull() + .$defaultFn(() => new Date()), + completedAt: integer("completed_at", { mode: "timestamp_ms" }), + }, + (table) => [ + uniqueIndex("agent_submissions_connection_client_idx").on(table.connectionId, table.clientSubmissionId), + index("agent_submissions_recent_idx").on(table.connectionId, table.createdAt), + ], +); + +/** 外部 Agent 访问事件(PRD 7.5 调试页):每次 MCP 调用/连接/提交一行,每连接保留最近 200 条。 */ +export const agentAccessEvents = sqliteTable( + "agent_access_events", + { + id: text("id").primaryKey(), + connectionId: text("connection_id").notNull().references(() => agentConnections.id, { onDelete: "cascade" }), + kind: text("kind", { enum: ["call", "connect", "submit"] }).notNull(), + tool: text("tool"), + status: text("status").notNull(), + durationMs: integer("duration_ms"), + detail: text("detail", { mode: "json" }).$type | null>(), + createdAt: integer("created_at", { mode: "timestamp_ms" }) + .notNull() + .$defaultFn(() => new Date()), + }, + (table) => [index("agent_access_events_recent_idx").on(table.connectionId, table.createdAt)], +); diff --git a/apps/gateway/src/modules/agent-access/install.ts b/apps/gateway/src/modules/agent-access/install.ts new file mode 100644 index 00000000..dbd182db --- /dev/null +++ b/apps/gateway/src/modules/agent-access/install.ts @@ -0,0 +1,171 @@ +import type { AgentAccessToolTarget, AgentConnectionView } from "./types.js"; + +/** + * 安装代写内容(PRD 7.1.3「安装技能与 MCP/CLI 连接」):网关只生成内容, + * 桌面连接页(阶段 2)负责把 skill 文件写入各工具目录、把配置片段并入对应 + * 配置文件。skill 遵循 agentskills.io 开放标准六字段(可移植到 Cursor/Codex 等)。 + */ + +export interface InstallPlan { + target: AgentAccessToolTarget; + url: string; + headers: Record; + /** 目标工具的 MCP 配置片段(JSON/TOML 文本,桌面端原样并入)。 */ + mcpConfig: string; + /** 需写入的 skill 文件(相对 skill 根目录的路径 → 内容)。 */ + skillFiles: Array<{ path: string; content: string }>; + /** skill 安装根目录(空表示该宿主无 skill 机制)。 */ + skillRoot: string | null; + notes: string[]; +} + +function skillBody(connection: AgentConnectionView, url: string): string { + return `--- +name: everroom-agent +description: 在当前工具中继续使用 EverRoom 的积累:搜索 Room/会话/文档/文件/记忆、精读原文、按授权范围回写结果与候选。当任务需要找回历史讨论、核查出处、复用个人知识或把成果保存回 EverRoom 时使用。 +compatibility: 需要 MCP 客户端(HTTP 传输);Claude Code、Cursor、Codex、Cherry Studio 等均支持。 +metadata: + connection: ${connection.name} + gateway: ${url} +--- + +# EverRoom Agent + +你已接入 EverRoom(连接名「${connection.name}」)。本技能约定使用姿势;能力由 MCP 服务器 \`${url}\` 提供。 + +## 工作流 + +1. **了解范围**:先调用 \`everroom_get_status\`(或 \`everroom_list_rooms\`)确认授权的 Room 与能力。 +2. **搜索**:\`everroom_search { query, kinds? }\` 跨类型检索。命中带稳定对象 ID、版本与 \`everroom://\` URI;会话命中含 conversationId/messageId。 +3. **精读**:\`everroom_fetch { uri, basedOnVersion?, offset? }\` 读原文;引用时携带返回的 \`version\`。若返回 \`stale\`,说明你手上的副本过期——内容仍是最新版,更新引用即可。 +4. **读文件**:\`everroom_read_file { fileId, variant }\`(markdown 或 base64)。 +5. **回写**:\`everroom_submit { submissionId, items[] }\`——submissionId 由你生成(建议 \`任务标识-序号\`),重试必须复用同一个(幂等,重放返回 duplicate 与原结果)。可提交:会话增量、结果文本、产物文件、来源引用、记忆/Wiki/偏好候选。 +6. **规则**:\`everroom_get_rules\` 读取本次适用的写作风格与偏好。 + +## 状态语义(所有工具的 structuredContent.status) + +- \`ok\` 正常;\`empty\` 无命中(换关键词或补问用户);\`no_permission\` 超出授权范围(附所需能力);\`source_lost\` 来源已删(换来源);\`parse_failed\` 解析失败(换变体/来源);\`stale\` 副本过期(data 为最新内容);\`duplicate\` 提交重放。 + +## 约束 + +- 候选(记忆/Wiki/偏好)只进入待确认队列:未经用户确认,不要声称"已记住"。 +- 遇到失败先 \`everroom_get_status\` 判断能力/服务状态,不要盲目重试。 +- 引用 EverRoom 内容时给出对象 ID 与版本,便于用户回溯原文。 +`; +} + +function toolsReference(): string { + return `# everroom 工具速查 + +- \`everroom_get_status\` — 连接与能力自述(常驻可见) +- \`everroom_list_rooms\` { } — 授权范围内的 Room(revision/更新时间)[read_metadata] +- \`everroom_search\` { query, kinds?: room|conversation|document|file|artifact|memory, limit?, offset? } [search] +- \`everroom_fetch\` { uri, basedOnVersion?, offset?, limit?, variant? } — 精读 everroom:// URI [按目标分能力] +- \`everroom_read_file\` { fileId, variant?: markdown|content } [read_files] +- \`everroom_get_rules\` { } — 本次规则(写作风格/偏好底料)[read_rules] +- \`everroom_register_file\` { filename, contentBase64, roomId? } [register_files] +- \`everroom_submit\` { submissionId, items[] } — 幂等回写(session_increment/result_text/artifact_file/citation/candidate)[write_session|write_candidates] +- \`everroom_submit_status\` { submissionId } — 查询提交结果(常驻可见) + +URI 空间:\`everroom://rooms/{id}\`、\`everroom://conversations/{sessionId}\`、\`everroom://documents/{id}\`、\`everroom://files/{id}\`、\`everroom://memory/{id}\`;人类可打开位置为 \`everroom://open/{kind}/{id}\`(桌面端注册后可用)。 +`; +} + +export function buildInstallPlan(input: { + target: AgentAccessToolTarget; + connection: AgentConnectionView; + url: string; + token: string; +}): InstallPlan { + const { target, connection, url, token } = input; + const headers = { Authorization: `Bearer ${token}` }; + const skillFiles = [ + { path: "SKILL.md", content: skillBody(connection, url) }, + { path: "references/tools.md", content: toolsReference() }, + ]; + const skillRootByTarget: Record = { + "claude-code": "~/.claude/skills/everroom-agent/", + cursor: "~/.cursor/skills/everroom-agent/", + codex: "~/.codex/skills/everroom-agent/", + "cherry-studio": null, + openclaw: null, + hermes: null, + generic: null, + }; + const notes = [ + "网关当前地址写入配置;端口随网关重启变化时需重新安装(固定端口在桌面连接页后续版本提供)。", + ]; + + switch (target) { + case "claude-code": + return { + target, + url, + headers, + mcpConfig: JSON.stringify( + { mcpServers: { everroom: { type: "http", url, headers } } }, + null, + 2, + ), + skillFiles, + skillRoot: skillRootByTarget[target], + notes: [ + ...notes, + "项目级写入 .mcp.json;用户级写入 ~/.claude.json 的 mcpServers(Claude Code 会请求批准项目级服务器)。", + ], + }; + case "cursor": + return { + target, + url, + headers, + mcpConfig: JSON.stringify({ mcpServers: { everroom: { url, headers } } }, null, 2), + skillFiles, + skillRoot: skillRootByTarget[target], + notes: [...notes, "写入 ~/.cursor/mcp.json(用户级);Cursor 兼容读取 ~/.claude/skills 目录。"], + }; + case "codex": + return { + target, + url, + headers, + mcpConfig: [ + "[mcp_servers.everroom]", + `url = "${url}"`, + 'http_headers = { Authorization = "Bearer " }', + ].join("\n"), + skillFiles, + skillRoot: skillRootByTarget[target], + notes: [ + ...notes, + "写入 ~/.codex/config.toml; 占位需替换为本响应 headers 中的实际值(TOML 不便嵌 JSON,桌面端落盘时替换)。", + ], + }; + case "cherry-studio": + return { + target, + url, + headers, + mcpConfig: JSON.stringify({ mcpServers: { everroom: { url, headers } } }, null, 2), + skillFiles, + skillRoot: null, + notes: [ + ...notes, + "Cherry Studio 在 设置 → MCP 服务器 手动添加(类型 StreamableHTTP);按目标版本验证支持情况(PRD 7.3)。", + ], + }; + case "openclaw": + case "hermes": + case "generic": + default: + return { + target, + url, + headers, + mcpConfig: JSON.stringify({ mcpServers: { everroom: { url, headers } } }, null, 2), + skillFiles, + skillRoot: skillRootByTarget[target], + notes: [...notes, "按宿主实际支持的 MCP 配置方式接入;技能文件内容可手工放入宿主技能目录。"], + }; + } +} diff --git a/apps/gateway/src/modules/agent-access/mcp-host.ts b/apps/gateway/src/modules/agent-access/mcp-host.ts new file mode 100644 index 00000000..084a87ad --- /dev/null +++ b/apps/gateway/src/modules/agent-access/mcp-host.ts @@ -0,0 +1,193 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js"; +import { + CallToolRequestSchema, + ListToolsRequestSchema, + type JSONRPCMessage, +} from "@modelcontextprotocol/sdk/types.js"; +import { ExchangeTransport } from "../agent/channel-mcp-host.js"; +import type { AgentAccessService } from "./service.js"; +import type { AgentAccessTool } from "./tools-read.js"; +import type { AgentAccessState, ResolvedAgentConnection } from "./types.js"; + +/** + * 外部 Agent 访问的 MCP host(PRD 第 7 章 / 附录 A.3)。 + * + * 与 ChannelMcpHost 的关键差异: + * - 凭证是持久化的连接 token(Authorization: Bearer,agent_connections 落库), + * 而非进程内存的 24h token——网关重启后连接仍有效。 + * - 会话自愈:重启后客户端未重新 initialize 直接调用时自动重建会话 + * (渠道版此时报错;持久连接的客户端可能带着旧状态直接续用)。 + * - 工具面按连接 capabilities 过滤,数据按 roomIds 过滤(tools 层执行)。 + * - 每次调用落 agent_access_events(PRD 7.5 调试页;只记工具名/状态/耗时/参数 + * 体积,不记内容)。 + */ + +const SERVER_INSTRUCTIONS = [ + "本服务器把 EverRoom 的积累(Room、会话、文档、文件、记忆)按授权范围提供给外部 Agent。", + "推荐工作流:everroom_list_rooms 了解范围 → everroom_search 检索 → 命中项用 everroom_fetch 按 everroom:// URI 精读原文 → 引用时携带返回的 version 字段。", + "所有工具返回 structuredContent.status:ok / empty(换关键词或补问用户)/ no_permission(范围外,附所需能力)/ source_lost(换来源)/ parse_failed(换变体或来源)/ stale(版本过期,data 仍是最新内容)/ duplicate(提交重放,返回原结果)。", + "回写用 everroom_submit:submissionId 由你生成并在重试时复用(幂等);候选(记忆/Wiki/偏好)进入待确认队列,未经用户确认不会写入长期记忆。", + "遇到失败先调用 everroom_get_status 判断是能力未开还是服务暂不可用,避免盲目重试。", +].join(" "); + +/** isError 语义:ok/empty/stale/duplicate/pending_review 是正常业务结果,其余按工具错误上抛。 */ +const ERROR_LIKE_STATES = new Set(["no_permission", "source_lost", "parse_failed", "invalid_input"]); + +interface HostSession { + server: Server; + transport: ExchangeTransport; + tools: AgentAccessTool[]; + connection: ResolvedAgentConnection; +} + +export class AgentAccessMcpHost { + private readonly sessions = new Map>(); + + constructor( + private readonly service: AgentAccessService, + private readonly toolsFactory: (connection: ResolvedAgentConnection) => AgentAccessTool[], + private readonly log?: ( + level: "info" | "warn" | "error", + event: string, + fields?: Record, + ) => void, + ) {} + + async exchangeTrusted( + bearerToken: unknown, + message: Record, + ): Promise[]> { + const token = typeof bearerToken === "string" ? bearerToken.replace(/^Bearer\s+/i, "").trim() : ""; + const connection = token ? this.service.verifyBearer(token) : null; + if (!connection) { + throw new Error("MCP_CONNECTION_INVALID: EverRoom connection token is missing, paused or revoked"); + } + if (message.jsonrpc !== "2.0") throw new Error("Invalid MCP JSON-RPC message"); + + if (message.method === "initialize") { + const previous = this.sessions.get(connection.id); + if (previous) await (await previous).server.close().catch(() => undefined); + this.sessions.set(connection.id, this.createSession(connection)); + const clientInfo = (message.params as { clientInfo?: { name?: string } } | undefined)?.clientInfo; + this.service.recordEvent(connection.id, "connect", { + status: "ok", + detail: { client: clientInfo?.name ?? null }, + }); + } else if (!this.sessions.has(connection.id)) { + this.sessions.set(connection.id, this.createSession(connection)); + } + const session = await this.sessions.get(connection.id)!; + return (await session.transport.exchange(message as JSONRPCMessage)) as Record[]; + } + + /** DELETE /v1/mcp/agents:只结束进程内会话;连接本身与凭证不受影响。 */ + async closeSession(bearerToken: unknown): Promise { + const token = typeof bearerToken === "string" ? bearerToken.replace(/^Bearer\s+/i, "").trim() : ""; + const connection = token ? this.service.verifyBearer(token) : null; + if (!connection) return; + const session = this.sessions.get(connection.id); + this.sessions.delete(connection.id); + if (session) await (await session).server.close().catch(() => undefined); + } + + /** REST 侧(自检/示例查询)直接执行某连接的工具;可见性规则与 MCP 调用一致。 */ + async runTool( + connection: ResolvedAgentConnection, + name: string, + args: Record, + ): Promise { + const tools = this.toolsFactory(connection); + const tool = tools.find((entry) => entry.name === name && (entry.capabilities === "always" + || entry.capabilities.some((capability) => connection.scopes.capabilities.includes(capability)))); + if (!tool) throw new Error(`agent_access_tool_unavailable:${name}`); + const startedAt = Date.now(); + const result = await tool.execute(args); + this.service.recordEvent(connection.id, "call", { + tool: name, + status: result.status, + durationMs: Date.now() - startedAt, + detail: { internal: true }, + }); + return result; + } + + async close(): Promise { + const sessions = await Promise.allSettled(this.sessions.values()); + this.sessions.clear(); + await Promise.all(sessions.flatMap((result) => result.status === "fulfilled" + ? [result.value.server.close().catch(() => undefined)] + : [])); + } + + private createSession(connection: ResolvedAgentConnection): Promise { + const transport = new ExchangeTransport(); + const tools = this.toolsFactory(connection); + const holder: HostSession = { + server: null as unknown as Server, + transport, + tools, + connection, + }; + const visibleTools = () => tools.filter((tool) => tool.capabilities === "always" + || tool.capabilities.some((capability) => connection.scopes.capabilities.includes(capability))); + + const server = new Server( + { name: "everroom-agent-access", version: "1.0.0" }, + { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }, + ); + holder.server = server; + + server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: visibleTools().map((tool) => ({ + name: tool.name, + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + annotations: tool.annotations, + })), + })); + + server.setRequestHandler(CallToolRequestSchema, async (request) => { + const name = request.params.name; + const args = request.params.arguments ?? {}; + const tool = visibleTools().find((entry) => entry.name === name); + if (!tool) { + return { + content: [{ type: "text" as const, text: `Unknown tool: ${name}` }], + isError: true, + }; + } + const startedAt = Date.now(); + try { + const result: AgentAccessState = await tool.execute(args as Record); + this.service.recordEvent(connection.id, "call", { + tool: name, + status: result.status, + durationMs: Date.now() - startedAt, + detail: { argsBytes: JSON.stringify(args).length }, + }); + return { + content: [{ type: "text" as const, text: JSON.stringify(result) }], + structuredContent: result as unknown as Record, + isError: ERROR_LIKE_STATES.has(result.status), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.log?.("warn", "agent_access.tool.error", { tool: name, connectionId: connection.id, message }); + this.service.recordEvent(connection.id, "call", { + tool: name, + status: "error", + durationMs: Date.now() - startedAt, + detail: { message }, + }); + return { + content: [{ type: "text" as const, text: JSON.stringify({ status: "parse_failed", message }) }], + structuredContent: { status: "parse_failed", message }, + isError: true, + }; + } + }); + + return server.connect(transport).then(() => holder); + } +} diff --git a/apps/gateway/src/modules/agent-access/mcp-routes.ts b/apps/gateway/src/modules/agent-access/mcp-routes.ts new file mode 100644 index 00000000..c2899c05 --- /dev/null +++ b/apps/gateway/src/modules/agent-access/mcp-routes.ts @@ -0,0 +1,58 @@ +import type { FastifyPluginAsyncTypebox } from "@fastify/type-provider-typebox"; +import { Type } from "@sinclair/typebox"; +import type { AgentAccessMcpHost } from "./mcp-host.js"; + +const JsonRpcMessage = Type.Object({ + jsonrpc: Type.Literal("2.0"), +}, { additionalProperties: true }); + +/** + * 外部 Agent 访问的 MCP 端点(PRD 第 7 章):Authorization: Bearer <连接token> + * 自鉴权(auth 插件对 /v1/mcp/agents 豁免全局 bearer)。无效/暂停/吊销的 + * token 返回 401 + JSON-RPC -32001,与渠道 MCP 的错误形状同族。 + */ +export function agentAccessMcpRoutes(host: AgentAccessMcpHost): FastifyPluginAsyncTypebox { + return async (app) => { + app.post( + "/v1/mcp/agents", + { schema: { tags: ["agent-access", "mcp"], body: JsonRpcMessage } }, + async (request, reply) => { + try { + const messages = await host.exchangeTrusted(request.headers.authorization, request.body); + if (messages.length === 0) return reply.code(202).send(); + return reply + .type("application/json") + .send(messages.length === 1 ? messages[0] : messages); + } catch (error) { + if (error instanceof Error && error.message.startsWith("MCP_CONNECTION_INVALID:")) { + return reply.code(401).send({ + jsonrpc: "2.0", + error: { code: -32001, message: "EverRoom connection token is missing, paused or revoked" }, + id: "id" in request.body ? request.body.id ?? null : null, + }); + } + throw error; + } + }, + ); + + app.get( + "/v1/mcp/agents", + { schema: { tags: ["agent-access", "mcp"] } }, + async (_request, reply) => reply.code(405).send({ + jsonrpc: "2.0", + error: { code: -32000, message: "Method not allowed" }, + id: null, + }), + ); + + app.delete( + "/v1/mcp/agents", + { schema: { tags: ["agent-access", "mcp"] } }, + async (request, reply) => { + await host.closeSession(request.headers.authorization); + return reply.code(204).send(); + }, + ); + }; +} diff --git a/apps/gateway/src/modules/agent-access/routes.ts b/apps/gateway/src/modules/agent-access/routes.ts new file mode 100644 index 00000000..0ec15b7e --- /dev/null +++ b/apps/gateway/src/modules/agent-access/routes.ts @@ -0,0 +1,290 @@ +import type { FastifyPluginAsyncTypebox } from "@fastify/type-provider-typebox"; +import { Type } from "@sinclair/typebox"; +import { buildInstallPlan } from "./install.js"; +import type { AgentAccessMcpHost } from "./mcp-host.js"; +import type { AgentAccessService } from "./service.js"; +import type { AgentAccessState, AgentAccessScopes, ResolvedAgentConnection } from "./types.js"; +import { isAgentAccessCapability, isAgentAccessToolTarget } from "./types.js"; + +/** + * Agent 连接页 REST(PRD 7.1/7.5):签发/暂停/更新/吊销连接、自检、示例查询、 + * 安装代写内容与调试数据。全部走网关全局 bearer(仅桌面端调用); + * 对外 MCP 端点在 mcp-routes.ts,以连接 token 自鉴权。 + */ + +const ScopesSchema = Type.Object({ + roomIds: Type.Union([ + Type.Literal("all"), + Type.Array(Type.String({ minLength: 1, maxLength: 128 }), { maxItems: 500 }), + ]), + capabilities: Type.Array(Type.String({ minLength: 1, maxLength: 40 }), { minItems: 1, maxItems: 20 }), + writebackRoomId: Type.Union([Type.String({ minLength: 1, maxLength: 128 }), Type.Null()]), +}, { additionalProperties: false }); + +const CreateConnectionBody = Type.Object({ + name: Type.String({ minLength: 1, maxLength: 80 }), + tool: Type.String({ minLength: 1, maxLength: 40 }), + scopes: ScopesSchema, +}, { additionalProperties: false }); + +const PatchConnectionBody = Type.Object({ + name: Type.Optional(Type.String({ minLength: 1, maxLength: 80 })), + scopes: Type.Optional(ScopesSchema), + status: Type.Optional(Type.Union([Type.Literal("active"), Type.Literal("paused")])), +}, { additionalProperties: false }); + +const IdParams = Type.Object({ id: Type.String({ minLength: 1, maxLength: 64 }) }); + +function parseScopes(value: { + roomIds: string[] | "all"; + capabilities: string[]; + writebackRoomId: string | null; +}): AgentAccessScopes { + const capabilities = value.capabilities.filter(isAgentAccessCapability); + if (capabilities.length === 0) throw new Error("agent_access_capabilities_invalid"); + return { + roomIds: value.roomIds, + capabilities, + writebackRoomId: value.writebackRoomId, + }; +} + +function resolvedFrom(view: { + id: string; + name: string; + tool: string; + scopes: AgentAccessScopes; +}): ResolvedAgentConnection { + return { id: view.id, name: view.name, tool: view.tool, scopes: view.scopes }; +} + +/** 自检兜底:工具不可见(能力未授予)时返回与工具一致的状态形状。 */ +function unavailableState(error: unknown): AgentAccessState { + const message = error instanceof Error ? error.message : String(error); + return message.startsWith("agent_access_tool_unavailable") + ? { status: "no_permission", message: "连接未授予该能力(自检步骤跳过)" } + : { status: "parse_failed", message }; +} + +export function agentAccessRoutes( + service: AgentAccessService, + host: AgentAccessMcpHost, + baseUrl: () => string, +): FastifyPluginAsyncTypebox { + return async (app) => { + app.get( + "/v1/agent-access/connections", + { schema: { tags: ["agent-access"] } }, + async () => ({ connections: service.listConnections() }), + ); + + app.post( + "/v1/agent-access/connections", + { + schema: { + tags: ["agent-access"], + body: CreateConnectionBody, + }, + }, + async (request, reply) => { + if (!isAgentAccessToolTarget(request.body.tool)) { + return reply.code(400).send({ error: "invalid_tool", message: "Unsupported target tool" }); + } + try { + const created = service.createConnection({ + name: request.body.name, + tool: request.body.tool, + scopes: parseScopes(request.body.scopes), + }); + return reply.code(201).send(created); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message === "agent_access_capabilities_invalid" || message === "agent_access_name_required") { + return reply.code(400).send({ error: "invalid_connection", message }); + } + throw error; + } + }, + ); + + app.get( + "/v1/agent-access/connections/:id", + { schema: { tags: ["agent-access"], params: IdParams } }, + async (request, reply) => { + const connection = service.getConnection(request.params.id); + if (!connection) return reply.code(404).send({ error: "not_found" }); + return { connection }; + }, + ); + + app.patch( + "/v1/agent-access/connections/:id", + { schema: { tags: ["agent-access"], params: IdParams, body: PatchConnectionBody } }, + async (request, reply) => { + const current = service.getConnection(request.params.id); + if (!current) return reply.code(404).send({ error: "not_found" }); + try { + const updated = service.updateConnection(request.params.id, { + ...(request.body.name !== undefined ? { name: request.body.name } : {}), + ...(request.body.scopes !== undefined ? { scopes: parseScopes(request.body.scopes) } : {}), + ...(request.body.status !== undefined ? { status: request.body.status } : {}), + }); + return { connection: updated }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message === "agent_access_capabilities_invalid" || message === "agent_access_name_required") { + return reply.code(400).send({ error: "invalid_connection", message }); + } + throw error; + } + }, + ); + + app.delete( + "/v1/agent-access/connections/:id", + { schema: { tags: ["agent-access"], params: IdParams } }, + async (request, reply) => { + const revoked = service.revokeConnection(request.params.id); + if (!revoked) return reply.code(404).send({ error: "not_found" }); + return { connection: revoked }; + }, + ); + + app.post( + "/v1/agent-access/connections/:id/token", + { schema: { tags: ["agent-access"], params: IdParams } }, + async (request, reply) => { + const rotated = service.rotateToken(request.params.id); + if (!rotated) return reply.code(404).send({ error: "not_found" }); + return rotated; + }, + ); + + app.get( + "/v1/agent-access/connections/:id/submissions", + { + schema: { + tags: ["agent-access"], + params: IdParams, + querystring: Type.Object({ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })) }), + }, + }, + async (request, reply) => { + if (!service.getConnection(request.params.id)) { + return reply.code(404).send({ error: "not_found" }); + } + return { submissions: service.listSubmissions(request.params.id, request.query.limit ?? 50) }; + }, + ); + + app.get( + "/v1/agent-access/connections/:id/events", + { + schema: { + tags: ["agent-access"], + params: IdParams, + querystring: Type.Object({ limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })) }), + }, + }, + async (request, reply) => { + if (!service.getConnection(request.params.id)) { + return reply.code(404).send({ error: "not_found" }); + } + return { events: service.listEvents(request.params.id, request.query.limit ?? 50) }; + }, + ); + + // PRD 7.1.3:安装后检查搜索、精读和候选回写是否可用。 + app.post( + "/v1/agent-access/connections/:id/selftest", + { schema: { tags: ["agent-access"], params: IdParams } }, + async (request, reply) => { + const view = service.getConnection(request.params.id); + if (!view) return reply.code(404).send({ error: "not_found" }); + const connection = resolvedFrom(view); + const list = await host.runTool(connection, "everroom_list_rooms", {}).catch(unavailableState); + const firstRoom = list.status === "ok" + ? ((list.data as { rooms?: Array<{ title?: string; roomId: string }> })?.rooms ?? [])[0] + : undefined; + const query = firstRoom?.title ?? "EverRoom"; + const search = await host + .runTool(connection, "everroom_search", { query, limit: 3 }) + .catch(unavailableState); + const fetch = firstRoom + ? await host + .runTool(connection, "everroom_fetch", { uri: `everroom://rooms/${firstRoom.roomId}` }) + .catch(unavailableState) + : ({ status: "empty", message: "授权范围内没有 Room 可精读" } satisfies AgentAccessState); + const submit = await host + .runTool(connection, "everroom_submit", { + submissionId: `selftest-${Date.now()}`, + items: [{ kind: "citation", targetUri: "everroom://selftest", note: "连接自检(可忽略)" }], + }) + .catch(unavailableState); + return { steps: { list, search, fetch, submit }, query }; + }, + ); + + // PRD 7.1.4:展示一次示例查询及出处。 + app.post( + "/v1/agent-access/connections/:id/example-query", + { + schema: { + tags: ["agent-access"], + params: IdParams, + body: Type.Object({ query: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })) }), + }, + }, + async (request, reply) => { + const view = service.getConnection(request.params.id); + if (!view) return reply.code(404).send({ error: "not_found" }); + const connection = resolvedFrom(view); + let query = request.body.query; + if (!query) { + const list = await host.runTool(connection, "everroom_list_rooms", {}).catch(unavailableState); + query = list.status === "ok" + ? (((list.data as { rooms?: Array<{ title?: string }> })?.rooms ?? [])[0]?.title ?? "EverRoom") + : "EverRoom"; + } + const result = await host + .runTool(connection, "everroom_search", { query, limit: 5 }) + .catch(unavailableState); + service.markExampleQuery(view.id); + return { query, result }; + }, + ); + + // 安装代写内容:需要创建/轮换时返回的明文 token 校验(库存只有哈希)。 + app.post( + "/v1/agent-access/connections/:id/install/:target", + { + schema: { + tags: ["agent-access"], + params: Type.Object({ + id: Type.String({ minLength: 1, maxLength: 64 }), + target: Type.String({ minLength: 1, maxLength: 40 }), + }), + body: Type.Object({ token: Type.String({ minLength: 8, maxLength: 128 }) }), + }, + }, + async (request, reply) => { + const view = service.getConnection(request.params.id); + if (!view) return reply.code(404).send({ error: "not_found" }); + if (!isAgentAccessToolTarget(request.params.target)) { + return reply.code(400).send({ error: "invalid_tool", message: "Unsupported target tool" }); + } + if (!service.verifyTokenMatch(view.id, request.body.token)) { + return reply.code(403).send({ error: "token_mismatch", message: "Token does not match this connection" }); + } + return { + install: buildInstallPlan({ + target: request.params.target, + connection: view, + url: `${baseUrl().replace(/\/+$/, "")}/v1/mcp/agents`, + token: request.body.token, + }), + }; + }, + ); + }; +} diff --git a/apps/gateway/src/modules/agent-access/service.ts b/apps/gateway/src/modules/agent-access/service.ts new file mode 100644 index 00000000..b3aff13f --- /dev/null +++ b/apps/gateway/src/modules/agent-access/service.ts @@ -0,0 +1,490 @@ +import { createHash, randomBytes, randomUUID } from "node:crypto"; +import { and, desc, eq, lt, sql } from "drizzle-orm"; +import type { GatewayDatabase } from "../../infrastructure/database/client.js"; +import { + agentAccessEvents, + agentConnections, + agentSubmissions, +} from "../../infrastructure/database/schema.js"; +import { + type AgentAccessScopes, + type AgentConnectionStatus, + type AgentConnectionView, + type AgentSubmissionItem, + type AgentSubmissionItemResult, + type AgentSubmissionOutcome, + type ResolvedAgentConnection, +} from "./types.js"; + +/** 每连接保留的调试事件条数(agent_access_events,PRD 7.5 调试页数据源)。 */ +const EVENT_RETENTION_PER_CONNECTION = 200; +/** 单次提交的条目上限(防止一次调用撑爆载荷)。 */ +const SUBMISSION_ITEM_LIMIT = 50; +const SUBMISSION_ID_PATTERN = /^[A-Za-z0-9._:-]{1,128}$/; + +export interface ArtifactImportOutcome { + fileEntryId: string; + contentHash: string; + filename: string; +} + +export interface AgentAccessServiceDeps { + /** + * 产物入库入口(filesService.importFileStream 的薄适配,sourceKind=agent-generated)。 + * 独立注入以便测试替换,也让本模块不依赖 files 服务的完整签名。 + */ + importArtifact: (input: { + filename: string; + buffer: Buffer; + roomId: string | null; + sourceKey: string; + }) => Promise; + log?: (level: "info" | "warn" | "error", event: string, fields?: Record) => void; +} + +type ConnectionRow = typeof agentConnections.$inferSelect; +type SubmissionRow = typeof agentSubmissions.$inferSelect; + +function mintToken(): { token: string; tokenHash: string; tokenPrefix: string } { + const token = `evr_${randomBytes(32).toString("base64url")}`; + return { + token, + tokenHash: createHash("sha256").update(token).digest("hex"), + tokenPrefix: token.slice(0, 12), + }; +} + +function toView(row: ConnectionRow): AgentConnectionView { + return { + id: row.id, + name: row.name, + tool: row.tool, + status: row.status, + scopes: row.scopes as AgentAccessScopes, + tokenPrefix: row.tokenPrefix, + createdAt: row.createdAt.toISOString(), + updatedAt: row.updatedAt.toISOString(), + lastSeenAt: row.lastSeenAt ? row.lastSeenAt.toISOString() : null, + revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null, + exampleQueryAt: row.exampleQueryAt ? row.exampleQueryAt.toISOString() : null, + }; +} + +export class AgentAccessService { + constructor( + private readonly db: GatewayDatabase, + private readonly deps: AgentAccessServiceDeps, + ) {} + + // ---------------------------------------------------------------- connections + + createConnection(input: { + name: string; + tool: string; + scopes: AgentAccessScopes; + }): { connection: AgentConnectionView; token: string } { + const name = input.name.trim().slice(0, 80); + if (!name) throw new Error("agent_access_name_required"); + const { token, tokenHash, tokenPrefix } = mintToken(); + const row: typeof agentConnections.$inferInsert = { + id: randomUUID(), + name, + tool: input.tool as typeof agentConnections.$inferInsert["tool"], + tokenHash, + tokenPrefix, + scopes: input.scopes, + status: "active", + }; + this.db.insert(agentConnections).values(row).run(); + const created = this.db.select().from(agentConnections).where(eq(agentConnections.id, row.id!)).get(); + this.deps.log?.("info", "agent_access.connection.created", { + connectionId: row.id, + tool: input.tool, + }); + return { connection: toView(created!), token }; + } + + listConnections(): AgentConnectionView[] { + return this.db + .select() + .from(agentConnections) + .orderBy(desc(agentConnections.createdAt)) + .all() + .map(toView); + } + + getConnection(id: string): AgentConnectionView | null { + const row = this.db.select().from(agentConnections).where(eq(agentConnections.id, id)).get(); + return row ? toView(row) : null; + } + + updateConnection( + id: string, + patch: { name?: string; scopes?: AgentAccessScopes; status?: Exclude }, + ): AgentConnectionView | null { + const row = this.db.select().from(agentConnections).where(eq(agentConnections.id, id)).get(); + if (!row || row.status === "revoked") return null; + const next: Partial = { updatedAt: new Date() }; + if (patch.name !== undefined) { + const name = patch.name.trim().slice(0, 80); + if (!name) throw new Error("agent_access_name_required"); + next.name = name; + } + if (patch.scopes !== undefined) next.scopes = patch.scopes; + if (patch.status !== undefined) next.status = patch.status; + this.db.update(agentConnections).set(next).where(eq(agentConnections.id, id)).run(); + return this.getConnection(id); + } + + revokeConnection(id: string): AgentConnectionView | null { + const row = this.db.select().from(agentConnections).where(eq(agentConnections.id, id)).get(); + if (!row || row.status === "revoked") return null; + this.db + .update(agentConnections) + .set({ status: "revoked", revokedAt: new Date(), updatedAt: new Date() }) + .where(eq(agentConnections.id, id)) + .run(); + this.deps.log?.("info", "agent_access.connection.revoked", { connectionId: id }); + return this.getConnection(id); + } + + rotateToken(id: string): { connection: AgentConnectionView; token: string } | null { + const row = this.db.select().from(agentConnections).where(eq(agentConnections.id, id)).get(); + if (!row || row.status === "revoked") return null; + const { token, tokenHash, tokenPrefix } = mintToken(); + this.db + .update(agentConnections) + .set({ tokenHash, tokenPrefix, updatedAt: new Date() }) + .where(eq(agentConnections.id, id)) + .run(); + this.deps.log?.("info", "agent_access.connection.token_rotated", { connectionId: id }); + return { connection: this.getConnection(id)!, token }; + } + + /** 安装代写流程校验:桌面端持创建时返回的明文 token 换取安装内容。 */ + verifyTokenMatch(id: string, token: string): boolean { + const row = this.db.select().from(agentConnections).where(eq(agentConnections.id, id)).get(); + if (!row) return false; + return row.tokenHash === createHash("sha256").update(token).digest("hex"); + } + + /** MCP 端点鉴权:仅 active 连接放行;顺带刷新 lastSeenAt。 */ + verifyBearer(token: string): ResolvedAgentConnection | null { + if (!token.startsWith("evr_")) return null; + const tokenHash = createHash("sha256").update(token).digest("hex"); + const row = this.db + .select() + .from(agentConnections) + .where(eq(agentConnections.tokenHash, tokenHash)) + .get(); + if (!row || row.status !== "active") return null; + this.db + .update(agentConnections) + .set({ lastSeenAt: new Date() }) + .where(eq(agentConnections.id, row.id)) + .run(); + return { + id: row.id, + name: row.name, + tool: row.tool, + scopes: row.scopes as AgentAccessScopes, + }; + } + + markExampleQuery(id: string): void { + this.db + .update(agentConnections) + .set({ exampleQueryAt: new Date() }) + .where(eq(agentConnections.id, id)) + .run(); + } + + // ---------------------------------------------------------------- submissions + + /** + * 幂等提交(PRD 7.4「重试复用同一提交标识」):(connectionId, clientSubmissionId) + * 唯一;重放返回已存结果并累计 duplicateCount。崩溃遗留的 received 行重入处理。 + */ + async submit( + connection: ResolvedAgentConnection, + clientSubmissionId: string, + items: AgentSubmissionItem[], + ): Promise { + if (!SUBMISSION_ID_PATTERN.test(clientSubmissionId)) { + throw new Error("agent_access_submission_id_invalid"); + } + if (!Array.isArray(items) || items.length === 0 || items.length > SUBMISSION_ITEM_LIMIT) { + throw new Error("agent_access_submission_items_invalid"); + } + + const existing = this.db + .select() + .from(agentSubmissions) + .where( + and( + eq(agentSubmissions.connectionId, connection.id), + eq(agentSubmissions.clientSubmissionId, clientSubmissionId), + ), + ) + .get(); + if (existing && existing.status !== "received") { + this.db + .update(agentSubmissions) + .set({ duplicateCount: existing.duplicateCount + 1, updatedAt: new Date() }) + .where(eq(agentSubmissions.id, existing.id)) + .run(); + this.recordEvent(connection.id, "submit", { + status: "duplicate", + detail: { clientSubmissionId }, + }); + return { + submissionId: existing.id, + clientSubmissionId, + status: "duplicate", + results: (existing.results as AgentSubmissionItemResult[] | null) ?? [], + }; + } + const rowId = existing?.id ?? randomUUID(); + if (!existing) { + this.db + .insert(agentSubmissions) + .values({ + id: rowId, + connectionId: connection.id, + clientSubmissionId, + items: items as unknown[], + status: "received", + }) + .run(); + } + + const results: AgentSubmissionItemResult[] = []; + for (let index = 0; index < items.length; index += 1) { + results.push(await this.processItem(connection, rowId, clientSubmissionId, index, items[index]!)); + } + const hasRejected = results.some((item) => item.status === "rejected"); + this.db + .update(agentSubmissions) + .set({ + status: hasRejected ? "partial_failed" : "completed", + results: results as unknown[], + completedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(agentSubmissions.id, rowId)) + .run(); + this.recordEvent(connection.id, "submit", { + status: hasRejected ? "partial_failed" : "ok", + detail: { clientSubmissionId, itemCount: items.length }, + }); + return { submissionId: rowId, clientSubmissionId, status: "accepted", results }; + } + + getSubmission(connectionId: string, clientSubmissionId: string): { + submissionId: string; + clientSubmissionId: string; + status: string; + itemCount: number; + results: AgentSubmissionItemResult[]; + duplicateCount: number; + createdAt: string; + } | null { + const row = this.db + .select() + .from(agentSubmissions) + .where( + and( + eq(agentSubmissions.connectionId, connectionId), + eq(agentSubmissions.clientSubmissionId, clientSubmissionId), + ), + ) + .get(); + return row ? this.toSubmissionView(row) : null; + } + + listSubmissions(connectionId: string, limit = 50): Array> { + return this.db + .select() + .from(agentSubmissions) + .where(eq(agentSubmissions.connectionId, connectionId)) + .orderBy(desc(agentSubmissions.createdAt)) + .limit(Math.min(Math.max(limit, 1), 200)) + .all() + .map((row) => this.toSubmissionView(row)); + } + + private toSubmissionView(row: SubmissionRow) { + return { + submissionId: row.id, + clientSubmissionId: row.clientSubmissionId, + status: row.status, + itemCount: (row.items as unknown[]).length, + results: (row.results as AgentSubmissionItemResult[] | null) ?? [], + duplicateCount: row.duplicateCount, + createdAt: row.createdAt.toISOString(), + }; + } + + private async processItem( + connection: ResolvedAgentConnection, + submissionId: string, + clientSubmissionId: string, + index: number, + item: AgentSubmissionItem, + ): Promise { + const base = { index, kind: item.kind }; + try { + if (item.kind === "session_increment") { + if (!item.platform || !item.threadId || !Array.isArray(item.messages) || item.messages.length === 0) { + return { ...base, status: "rejected", error: "session_increment_requires_platform_thread_messages" }; + } + // 载荷整体保存在 agent_submissions.items(含两链合并键:平台/线程/消息 ID+内容); + // 并入 external_agent_threads 同步链是阶段 2 集成点(设计文档 §5.4)。 + return { + ...base, + status: "recorded", + objectId: `${submissionId}:${index}`, + objectType: "session_increment_record", + }; + } + if (item.kind === "result_text" || item.kind === "artifact_file") { + const filename = + item.kind === "result_text" + ? `${this.sanitizeFilename(item.title) || "agent-result"}.md` + : item.filename; + const buffer = + item.kind === "result_text" + ? Buffer.from(item.content, "utf8") + : Buffer.from(item.contentBase64, "base64"); + const roomId = item.roomId ?? connection.scopes.writebackRoomId ?? null; + if (item.kind === "artifact_file" && roomId && !this.roomAllowed(connection, roomId)) { + return { ...base, status: "rejected", error: "room_out_of_scope" }; + } + const imported = await this.deps.importArtifact({ + filename, + buffer, + roomId, + sourceKey: `agent-sub:${connection.id}:${clientSubmissionId}:${index}`, + }); + return { + ...base, + status: "saved", + objectId: imported.fileEntryId, + objectType: "file_entry", + title: imported.filename, + deepLink: `everroom://open/file/${imported.fileEntryId}`, + }; + } + if (item.kind === "citation") { + return { + ...base, + status: "recorded", + objectId: `${submissionId}:${index}`, + objectType: "citation_record", + }; + } + // candidate:三路治理(PRD 6.3.6)接入前统一 pending_review 暂存, + // 不自动写记忆——宁可保守,不可重复污染(发布门槛 G3)。 + const candidate = item as Extract; + if (!candidate.content?.trim()) { + return { ...base, status: "rejected", error: "candidate_content_required" }; + } + return { + ...base, + status: "pending_review", + objectId: `${submissionId}:${index}`, + objectType: `candidate:${candidate.candidateType}`, + title: candidate.content.slice(0, 80), + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + return { ...base, status: "rejected", error: message }; + } + } + + private roomAllowed(connection: ResolvedAgentConnection, roomId: string): boolean { + return connection.scopes.roomIds === "all" || connection.scopes.roomIds.includes(roomId); + } + + private sanitizeFilename(title: string): string { + return title.replace(/[\\/:*?"<>|\n\r\t]/g, " ").trim().slice(0, 60); + } + + // ---------------------------------------------------------------- events + + recordEvent( + connectionId: string, + kind: "call" | "connect" | "submit", + fields: { tool?: string | null; status: string; durationMs?: number; detail?: Record | null }, + ): void { + this.db + .insert(agentAccessEvents) + .values({ + id: randomUUID(), + connectionId, + kind, + tool: fields.tool ?? null, + status: fields.status, + durationMs: fields.durationMs ?? null, + detail: fields.detail ?? null, + }) + .run(); + // 保留窗口裁剪:删掉本连接第 EVENT_RETENTION_PER_CONNECTION 条之前的旧事件。 + const cutoff = this.db + .select({ createdAt: agentAccessEvents.createdAt }) + .from(agentAccessEvents) + .where(eq(agentAccessEvents.connectionId, connectionId)) + .orderBy(desc(agentAccessEvents.createdAt)) + .limit(1) + .offset(EVENT_RETENTION_PER_CONNECTION) + .get(); + if (cutoff) { + this.db + .delete(agentAccessEvents) + .where( + and( + eq(agentAccessEvents.connectionId, connectionId), + lt(agentAccessEvents.createdAt, cutoff.createdAt), + ), + ) + .run(); + } + } + + listEvents(connectionId: string, limit = 50): Array<{ + id: string; + kind: string; + tool: string | null; + status: string; + durationMs: number | null; + detail: Record | null; + createdAt: string; + }> { + return this.db + .select() + .from(agentAccessEvents) + .where(eq(agentAccessEvents.connectionId, connectionId)) + .orderBy(desc(agentAccessEvents.createdAt)) + .limit(Math.min(Math.max(limit, 1), 200)) + .all() + .map((row) => ({ + id: row.id, + kind: row.kind, + tool: row.tool, + status: row.status, + durationMs: row.durationMs, + detail: row.detail ?? null, + createdAt: row.createdAt.toISOString(), + })); + } + + /** 调试页/自检用的提交计数。 */ + countSubmissions(connectionId: string): number { + const row = this.db + .select({ count: sql`count(*)` }) + .from(agentSubmissions) + .where(eq(agentSubmissions.connectionId, connectionId)) + .get(); + return row?.count ?? 0; + } +} diff --git a/apps/gateway/src/modules/agent-access/tools-read.ts b/apps/gateway/src/modules/agent-access/tools-read.ts new file mode 100644 index 00000000..4eaa6a89 --- /dev/null +++ b/apps/gateway/src/modules/agent-access/tools-read.ts @@ -0,0 +1,664 @@ +import { createHash } from "node:crypto"; +import { and, eq, isNull } from "drizzle-orm"; +import type { GatewayDatabase } from "../../infrastructure/database/client.js"; +import { + contextRooms, + knowledgePreferenceSettings, + knowledgePreferences, + roomMemoryAttributions, + roomOverviews, + roomSourceMemberships, + writingStyleSettings, +} from "../../infrastructure/database/schema.js"; +import type { + AgentAccessCapability, + AgentAccessState, + AgentAccessStateStatus, + ResolvedAgentConnection, +} from "./types.js"; +import { roomInScope } from "./types.js"; + +/** + * 对外 MCP 工具的结构化服务依赖(全部为窄接口,测试可替换; + * 与 channel-mcp-host 的 ChannelMcpTool 同哲学:能力在接口,方法在 Skill)。 + */ +export interface AgentAccessToolDeps { + db: GatewayDatabase; + search: { + search( + query: string, + filters: { types?: string[] | undefined; roomIds?: string[] | undefined }, + limit: number, + offset: number, + ): { + items: Array<{ + itemType: string; + refId: string; + title: string; + snippet: string; + roomIds: string[]; + versionLabel: string | null; + ingestedAt: string; + sessionId: string | null; + status: string; + }>; + total: number; + }; + status?(): unknown; + } | null; + memory: { + enabled: boolean; + searchAtomic( + query: string, + limit: number, + ): Promise<{ items: Array<{ id: string; content: string; updatedAt?: string | null }> }>; + searchRoomMemories( + roomId: string, + query: string, + limit: number, + ): Promise>; + } | null; + documents: { + get(documentId: string): { + id: string; + roomId: string; + title: string; + version: number; + deletedAt?: string | null; + } | null; + readDocumentForAgent( + documentId: string, + roomId: string, + ): { + document: { id: string; roomId: string; title: string; version: number }; + blocks: Array<{ blockId: string; type: string; textPreview: string }>; + markdown: string; + }; + } | null; + files: { + markdownOf(fileId: string): string | null; + contentOf(fileId: string): Promise<{ buffer: Buffer; mime: string; filename: string } | null>; + } | null; + agent: { + getSnapshot(sessionId: string): { + session: { id: string; roomId?: string | null; title?: string | null }; + messages: Array<{ id: string; role: string; content: string; createdAt: string }>; + } | null; + } | null; +} + +export interface AgentAccessTool { + name: string; + title: string; + description: string; + inputSchema: Record; + annotations: { + readOnlyHint: boolean; + destructiveHint: boolean; + idempotentHint: boolean; + openWorldHint: boolean; + }; + /** 工具可见所需能力;数组表示任一即可;"always" 表示任何有效连接可见。 */ + capabilities: AgentAccessCapability[] | "always"; + execute: (args: Record) => Promise; +} + +const MARKDOWN_CHAR_LIMIT = 80_000; +const FILE_BYTES_LIMIT = 5 * 1024 * 1024; + +function state(status: AgentAccessStateStatus, data?: T, message?: string): AgentAccessState { + const result: AgentAccessState = { status }; + if (data !== undefined) result.data = data; + if (message !== undefined) result.message = message; + return result; +} + +function deepLink(kind: string, id: string): string { + return `everroom://open/${kind}/${id}`; +} + +function hasCapability(connection: ResolvedAgentConnection, capability: AgentAccessCapability): boolean { + return connection.scopes.capabilities.includes(capability); +} + +function noPermission(capability: AgentAccessCapability): AgentAccessState { + return { + status: "no_permission", + requiredCapability: capability, + message: `本次连接未授予 ${capability} 能力;请在 EverRoom Agent 连接页调整范围,或向用户补问`, + }; +} + +function versionState( + payload: T, + basedOnVersion: string | null, +): AgentAccessState { + if (basedOnVersion !== null && basedOnVersion !== payload.version) { + return { + status: "stale", + latestVersion: payload.version, + data: payload, + message: "basedOnVersion 已过期;data 为最新内容,请刷新本地副本后引用", + }; + } + return { status: "ok", data: payload }; +} + +/** room_source_memberships(sourceKind=file)反查文件归属 Room。 */ +function roomIdsForFile(deps: AgentAccessToolDeps, fileId: string): string[] { + const rows = deps.db + .select({ roomId: roomSourceMemberships.roomId }) + .from(roomSourceMemberships) + .where(and(eq(roomSourceMemberships.sourceKind, "file"), eq(roomSourceMemberships.sourceId, fileId))) + .all(); + return rows.map((row) => row.roomId); +} + +/** 文件范围判定:有 Room 归属时按归属判;无归属条目仅 roomIds="all" 可读。 */ +function fileInScope(deps: AgentAccessToolDeps, connection: ResolvedAgentConnection, fileId: string): boolean { + const roomIds = roomIdsForFile(deps, fileId); + if (roomIds.length === 0) return connection.scopes.roomIds === "all"; + return roomIds.some((roomId) => roomInScope(connection.scopes, roomId)); +} + +const FETCH_URI_PATTERN = + /^everroom:\/\/(rooms|conversations|documents|files|memory)\/([A-Za-z0-9._:-]+)(?:\/overview)?$/; + +type FetchTarget = "rooms" | "conversations" | "documents" | "files" | "memory"; + +/** 读五件套 + 自述。按连接闭包构造(capabilities 过滤由 host 做,数据过滤在这里做)。 */ +export function createReadTools( + deps: AgentAccessToolDeps, + connection: ResolvedAgentConnection, +): AgentAccessTool[] { + const scopeRoomFilter = (): string[] | undefined => + connection.scopes.roomIds === "all" ? undefined : connection.scopes.roomIds; + + const fetchConversation = ( + sessionId: string, + args: Record, + basedOnVersion: string | null, + ): AgentAccessState => { + const snapshot = deps.agent?.getSnapshot(sessionId) ?? null; + if (!snapshot) return state("source_lost", undefined, "会话不存在或已删除;请换来源或补问用户"); + const roomId = snapshot.session.roomId ?? null; + if (!roomInScope(connection.scopes, roomId)) { + return state("no_permission", undefined, "会话不在本连接授权的 Room 范围内"); + } + const offset = Math.max(Number(args.offset ?? 0), 0); + const limit = Math.min(Math.max(Number(args.limit ?? 100), 1), 200); + const messages = snapshot.messages.slice(offset, offset + limit).map((message) => ({ + messageId: message.id, + conversationId: sessionId, + role: message.role, + content: message.content, + occurredAt: message.createdAt, + })); + const lastMessage = snapshot.messages[snapshot.messages.length - 1]; + return versionState( + { + kind: "conversation", + conversationId: sessionId, + title: snapshot.session.title ?? null, + roomId, + version: lastMessage?.createdAt ?? "empty", + totalMessages: snapshot.messages.length, + offset, + messages, + nextOffset: offset + messages.length < snapshot.messages.length ? offset + messages.length : null, + uri: `everroom://conversations/${sessionId}`, + deepLink: deepLink("conversation", sessionId), + }, + basedOnVersion, + ); + }; + + const fetchDocument = (documentId: string, basedOnVersion: string | null): AgentAccessState => { + const document = deps.documents?.get(documentId) ?? null; + if (!document || document.deletedAt) { + return state("source_lost", undefined, "文档不存在、已删除或已进回收站"); + } + if (!roomInScope(connection.scopes, document.roomId)) { + return state("no_permission", undefined, "文档不在本连接授权的 Room 范围内"); + } + let read: ReturnType["readDocumentForAgent"]>; + try { + read = deps.documents!.readDocumentForAgent(documentId, document.roomId); + } catch { + return state("parse_failed", undefined, "文档正文序列化失败;可改用 everroom_search 的命中片段"); + } + const truncated = read.markdown.length > MARKDOWN_CHAR_LIMIT; + return versionState( + { + kind: "document", + documentId, + roomId: document.roomId, + title: read.document.title, + version: `v${read.document.version}`, + markdown: truncated ? read.markdown.slice(0, MARKDOWN_CHAR_LIMIT) : read.markdown, + truncated, + blocks: read.blocks.slice(0, 200).map((block) => ({ + blockId: block.blockId, + type: block.type, + preview: block.textPreview, + })), + uri: `everroom://documents/${documentId}`, + deepLink: deepLink("document", documentId), + }, + basedOnVersion, + ); + }; + + const fetchFile = async ( + fileId: string, + args: Record, + ): Promise => { + const variant = args.variant === "content" ? "content" : "markdown"; + const loaded = (await deps.files?.contentOf(fileId).catch(() => null)) ?? null; + if (!loaded) return state("source_lost", undefined, "文件不存在或已删除"); + if (!fileInScope(deps, connection, fileId)) { + return state("no_permission", undefined, "文件不在本连接授权的 Room 范围内"); + } + const contentHash = createHash("sha256").update(loaded.buffer).digest("hex"); + const base = { + kind: "file", + fileId, + filename: loaded.filename, + mime: loaded.mime, + byteSize: loaded.buffer.byteLength, + version: contentHash, + uri: `everroom://files/${fileId}`, + deepLink: deepLink("file", fileId), + }; + if (variant === "content") { + if (loaded.buffer.byteLength > FILE_BYTES_LIMIT) { + return state("invalid_input", base, `文件超过 ${FILE_BYTES_LIMIT} 字节上限;请用 markdown 变体`); + } + return state("ok", { ...base, contentBase64: loaded.buffer.toString("base64") }); + } + const markdown = deps.files?.markdownOf(fileId) ?? null; + if (markdown === null) { + return state("parse_failed", base, "该文件类型暂无 markdown 正文;可改用 content 变体取原始字节"); + } + const truncated = markdown.length > MARKDOWN_CHAR_LIMIT; + return state("ok", { + ...base, + markdown: truncated ? markdown.slice(0, MARKDOWN_CHAR_LIMIT) : markdown, + truncated, + }); + }; + + const fetchMemory = (memoryId: string, basedOnVersion: string | null): AgentAccessState => { + const row = deps.db + .select() + .from(roomMemoryAttributions) + .where(eq(roomMemoryAttributions.memoryId, memoryId)) + .get(); + if (!row) return state("source_lost", undefined, "记忆条目不存在(可能未绑定 Room 或已删除)"); + if (!roomInScope(connection.scopes, row.roomId)) { + return state("no_permission", undefined, "记忆条目不在本连接授权的 Room 范围内"); + } + return versionState( + { + kind: "memory", + memoryId, + roomId: row.roomId, + content: row.content, + confidence: row.confidence, + version: row.updatedAt.toISOString(), + uri: `everroom://memory/${memoryId}`, + deepLink: deepLink("memory", memoryId), + }, + basedOnVersion, + ); + }; + + const fetchRoomOverview = (roomId: string, basedOnVersion: string | null): AgentAccessState => { + const room = deps.db + .select() + .from(contextRooms) + .where(and(eq(contextRooms.id, roomId), isNull(contextRooms.deletedAt))) + .get(); + if (!room) return state("source_lost", undefined, "Room 不存在或已删除"); + if (!roomInScope(connection.scopes, roomId)) { + return state("no_permission", undefined, "Room 不在本连接授权范围内"); + } + const overview = deps.db.select().from(roomOverviews).where(eq(roomOverviews.roomId, roomId)).get(); + return versionState( + { + kind: "room_overview", + roomId, + title: room.title, + kindLabel: room.kind, + revision: overview?.revision ?? 0, + version: `rev${overview?.revision ?? 0}`, + synthesisAt: overview?.synthesisAt ? overview.synthesisAt.toISOString() : null, + projection: overview?.projection ?? null, + uri: `everroom://rooms/${roomId}`, + deepLink: deepLink("room", roomId), + }, + basedOnVersion, + ); + }; + + const capabilityByTarget: Record = { + rooms: "read_metadata", + conversations: "read_conversations", + documents: "read_documents", + files: "read_files", + memory: "search", + }; + + const fetchTool: AgentAccessTool = { + name: "everroom_fetch", + title: "按 URI 精读", + description: + "精读 everroom:// URI 指向的原文:会话消息(分页)、文档(markdown+块锚点)、文件、Room 概览与记忆条目。可传 basedOnVersion 校验副本新鲜度:版本过期时返回 stale 并附最新版本,内容仍随 data 返回(未刷新的副本可见)。", + inputSchema: { + type: "object", + properties: { + uri: { type: "string", pattern: "^everroom://", maxLength: 300 }, + basedOnVersion: { type: "string", maxLength: 128 }, + offset: { type: "integer", minimum: 0, maximum: 100000 }, + limit: { type: "integer", minimum: 1, maximum: 200 }, + variant: { type: "string", enum: ["markdown", "content"] }, + }, + required: ["uri"], + additionalProperties: false, + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + capabilities: ["read_conversations", "read_documents", "read_files", "read_metadata", "search"], + execute: async (args) => { + const match = FETCH_URI_PATTERN.exec(String(args.uri ?? "")); + if (!match) { + return state("invalid_input", undefined, "uri 无法解析;合法形如 everroom://conversations/{sessionId}"); + } + const target = match[1] as FetchTarget; + const id = match[2]!; + const basedOnVersion = args.basedOnVersion === undefined ? null : String(args.basedOnVersion); + if (!hasCapability(connection, capabilityByTarget[target])) { + return noPermission(capabilityByTarget[target]); + } + if (target === "conversations") return fetchConversation(id, args, basedOnVersion); + if (target === "documents") return fetchDocument(id, basedOnVersion); + if (target === "files") return await fetchFile(id, args); + if (target === "memory") return fetchMemory(id, basedOnVersion); + return fetchRoomOverview(id, basedOnVersion); + }, + }; + + const listRoomsTool: AgentAccessTool = { + name: "everroom_list_rooms", + title: "列出可访问的 Room", + description: + "发现元信息:列出本连接授权范围内的 Room(标题、类型、概览 revision、更新时间与可打开位置)。用于了解可检索的范围。", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + capabilities: ["read_metadata"], + execute: async () => { + const rooms = deps.db + .select({ + roomId: contextRooms.id, + title: contextRooms.title, + kind: contextRooms.kind, + updatedAt: contextRooms.updatedAt, + }) + .from(contextRooms) + .where(isNull(contextRooms.deletedAt)) + .all() + .filter((room) => roomInScope(connection.scopes, room.roomId)); + const overviews = new Map( + deps.db + .select({ roomId: roomOverviews.roomId, revision: roomOverviews.revision }) + .from(roomOverviews) + .all() + .map((row) => [row.roomId, row.revision]), + ); + const items = rooms.slice(0, 200).map((room) => ({ + roomId: room.roomId, + title: room.title, + kind: room.kind, + revision: overviews.get(room.roomId) ?? null, + updatedAt: room.updatedAt.toISOString(), + uri: `everroom://rooms/${room.roomId}`, + deepLink: deepLink("room", room.roomId), + })); + return items.length === 0 + ? state("empty", { rooms: [] }, "授权范围内没有 Room") + : state("ok", { rooms: items, total: rooms.length }); + }, + }; + + const searchTool: AgentAccessTool = { + name: "everroom_search", + title: "跨类型搜索", + description: + "搜索 EverRoom 积累:会话消息、文档、文件、Room 与原子记忆。每个命中带稳定对象 ID、版本、everroom:// URI 与精读入口;会话命中含 conversationId 与 messageId,用 everroom_fetch 继续精读原文。", + inputSchema: { + type: "object", + properties: { + query: { type: "string", minLength: 1, maxLength: 500 }, + kinds: { + type: "array", + items: { type: "string", enum: ["room", "conversation", "document", "file", "artifact", "memory"] }, + maxItems: 6, + }, + limit: { type: "integer", minimum: 1, maximum: 50 }, + offset: { type: "integer", minimum: 0, maximum: 10000 }, + }, + required: ["query"], + additionalProperties: false, + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + capabilities: ["search"], + execute: async (args) => { + const query = String(args.query ?? "").trim(); + const kinds = Array.isArray(args.kinds) && args.kinds.length > 0 + ? (args.kinds as string[]) + : ["room", "conversation", "document", "file", "artifact", "memory"]; + const limit = Math.min(Math.max(Number(args.limit ?? 20), 1), 50); + const offset = Math.max(Number(args.offset ?? 0), 0); + + const hits: Array> = []; + const typeByKind: Record = { + room: "room", + conversation: "message", + document: "document", + file: "file", + artifact: "export", + }; + const ftsKinds = kinds.filter((kind) => typeByKind[kind]); + if (deps.search && ftsKinds.length > 0) { + const page = deps.search.search( + query, + { types: ftsKinds.map((kind) => typeByKind[kind]!), roomIds: scopeRoomFilter() }, + limit, + offset, + ); + // 搜索命中的 uri 必须与 everroom_fetch 的解析方案一致(rooms/documents/files 复数段)。 + const uriSegmentByType: Record = { + room: "rooms", + document: "documents", + file: "files", + }; + hits.push( + ...page.items.map((item) => { + const kind = item.itemType === "message" + ? "conversation" + : item.itemType === "export" + ? "artifact" + : item.itemType; + const isMessage = item.itemType === "message" && item.sessionId; + const uri = isMessage + ? `everroom://conversations/${item.sessionId}` + : `everroom://${uriSegmentByType[item.itemType] ?? item.itemType}/${item.refId}`; + return { + kind, + objectId: item.refId, + title: item.title, + snippet: item.snippet, + roomIds: item.roomIds, + version: item.versionLabel, + updatedAt: item.ingestedAt, + indexed: item.status === "indexed", + uri, + ...(isMessage ? { conversationId: item.sessionId, messageId: item.refId } : {}), + deepLink: isMessage + ? deepLink("conversation", item.sessionId!) + : deepLink(kind, item.refId), + }; + }), + ); + } + if (deps.memory?.enabled && kinds.includes("memory")) { + try { + const memoryHits = connection.scopes.roomIds === "all" + ? (await deps.memory.searchAtomic(query, limit)).items.map((item) => ({ + kind: "memory", + objectId: item.id, + title: item.content.slice(0, 60), + snippet: item.content.slice(0, 200), + version: null as string | null, + updatedAt: item.updatedAt ?? null, + uri: `everroom://memory/${item.id}`, + deepLink: deepLink("memory", item.id), + })) + : (await Promise.all( + connection.scopes.roomIds.slice(0, 10).map((roomId) => + deps.memory!.searchRoomMemories(roomId, query, 5), + ), + )).flat().map((item) => ({ + kind: "memory", + objectId: item.memoryId, + title: item.content.slice(0, 60), + snippet: item.content.slice(0, 200), + version: null as string | null, + updatedAt: item.updatedAt ?? null, + uri: `everroom://memory/${item.memoryId}`, + deepLink: deepLink("memory", item.memoryId), + })); + hits.push(...memoryHits); + } catch { + // 记忆服务不可用不阻断 FTS 命中;可用性以 everroom_get_status 为准。 + } + } + return hits.length === 0 + ? state("empty", { hits: [], query }, "没有匹配结果;可换关键词、放宽类型或补问用户") + : state("ok", { hits, query, limit, offset }); + }, + }; + + const readFileTool: AgentAccessTool = { + name: "everroom_read_file", + title: "读取源文件", + description: + "读取源文件原文(markdown 或 base64 内容),带 sha256 内容指纹与版本。记忆/搜索命中的文件可用此工具继续读原文。", + inputSchema: { + type: "object", + properties: { + fileId: { type: "string", minLength: 1, maxLength: 200 }, + variant: { type: "string", enum: ["markdown", "content"] }, + }, + required: ["fileId"], + additionalProperties: false, + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + capabilities: ["read_files"], + execute: async (args) => { + const fileId = String(args.fileId ?? ""); + const result = await fetchFile(fileId, args); + if (result.status === "no_permission") return noPermission("read_files"); + return result; + }, + }; + + const getRulesTool: AgentAccessTool = { + name: "everroom_get_rules", + title: "获取本次规则", + description: + "获取本次任务适用的规则:连接授权范围摘要 + 当前生效的写作风格与整理偏好底料(含版本与开关)。统一技能/偏好/模板库上线后返回完整规则集。", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + capabilities: ["read_rules"], + execute: async () => { + const writing = deps.db.select().from(writingStyleSettings).get(); + const organizing = deps.db.select().from(knowledgePreferenceSettings).get(); + const preference = deps.db.select().from(knowledgePreferences).get(); + return state("ok", { + scope: { + roomIds: connection.scopes.roomIds, + capabilities: connection.scopes.capabilities, + writebackRoomId: connection.scopes.writebackRoomId, + }, + rules: [ + writing + ? { + type: "writing_style", + enabled: writing.generationEnabled || writing.completionEnabled, + version: `v${writing.configVersion}`, + updatedAt: writing.updatedAt.toISOString(), + } + : { type: "writing_style", enabled: false, version: null, updatedAt: null }, + organizing + ? { + type: "organizing_preference", + learningEnabled: organizing.learningEnabled, + injectionEnabled: organizing.injectionEnabled, + version: `v${organizing.configVersion}`, + updatedAt: organizing.updatedAt.toISOString(), + } + : { type: "organizing_preference", enabled: false, version: null, updatedAt: null }, + preference + ? { + type: "user_preference", + content: preference.userPreference.slice(0, 400), + userEdited: preference.userEdited, + updatedAt: preference.updatedAt.toISOString(), + } + : { type: "user_preference", content: "", userEdited: false, updatedAt: null }, + ], + note: "统一技能/偏好/模板库(PRD 6.7)上线后,本工具将返回完整规则集(触发场景、优先级、参考资源)", + }); + }, + }; + + const getStatusTool: AgentAccessTool = { + name: "everroom_get_status", + title: "连接与能力自述", + description: + "返回连接信息、各服务可用性(搜索索引、记忆服务)与授权范围。遇到失败先调用本工具判断是能力未开还是服务暂不可用,避免盲目重试。", + inputSchema: { type: "object", properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + capabilities: "always", + execute: async () => { + const scopedRooms = deps.db + .select({ id: contextRooms.id }) + .from(contextRooms) + .where(isNull(contextRooms.deletedAt)) + .all() + .filter((room) => roomInScope(connection.scopes, room.id)); + return state("ok", { + connection: { + name: connection.name, + tool: connection.tool, + capabilities: connection.scopes.capabilities, + roomIds: connection.scopes.roomIds, + writebackRoomId: connection.scopes.writebackRoomId, + }, + services: { + search: deps.search + ? { available: true, status: deps.search.status?.() ?? null } + : { available: false }, + memory: deps.memory + ? { available: true, enabled: deps.memory.enabled } + : { available: false }, + documents: deps.documents ? { available: true } : { available: false }, + files: deps.files ? { available: true } : { available: false }, + }, + scopedRoomCount: scopedRooms.length, + }); + }, + }; + + return [listRoomsTool, searchTool, fetchTool, readFileTool, getRulesTool, getStatusTool]; +} diff --git a/apps/gateway/src/modules/agent-access/tools-write.ts b/apps/gateway/src/modules/agent-access/tools-write.ts new file mode 100644 index 00000000..2883ae40 --- /dev/null +++ b/apps/gateway/src/modules/agent-access/tools-write.ts @@ -0,0 +1,209 @@ +import { createHash } from "node:crypto"; +import { isSupportedUploadFilename } from "../files/service.js"; +import type { AgentAccessService } from "./service.js"; +import type { AgentAccessState, AgentSubmissionItem, ResolvedAgentConnection } from "./types.js"; +import type { AgentAccessTool } from "./tools-read.js"; + +const FILE_BYTES_LIMIT = 20 * 1024 * 1024; + +function state(status: AgentAccessState["status"], data?: T, message?: string): AgentAccessState { + const result: AgentAccessState = { status }; + if (data !== undefined) result.data = data; + if (message !== undefined) result.message = message; + return result; +} + +const SUBMISSION_ITEM_SCHEMA: Record = { + oneOf: [ + { + type: "object", + properties: { + kind: { type: "string", const: "session_increment" }, + platform: { type: "string", maxLength: 40 }, + threadId: { type: "string", minLength: 1, maxLength: 200 }, + title: { type: "string", maxLength: 300 }, + messages: { + type: "array", + minItems: 1, + maxItems: 500, + items: { + type: "object", + properties: { + messageId: { type: "string", minLength: 1, maxLength: 200 }, + role: { type: "string", enum: ["user", "assistant"] }, + content: { type: "string", maxLength: 100_000 }, + occurredAt: { type: "string", maxLength: 40 }, + }, + required: ["messageId", "role", "content"], + additionalProperties: false, + }, + }, + }, + required: ["kind", "platform", "threadId", "messages"], + additionalProperties: false, + }, + { + type: "object", + properties: { + kind: { type: "string", const: "result_text" }, + title: { type: "string", minLength: 1, maxLength: 200 }, + content: { type: "string", minLength: 1, maxLength: 500_000 }, + roomId: { type: "string", maxLength: 128 }, + }, + required: ["kind", "title", "content"], + additionalProperties: false, + }, + { + type: "object", + properties: { + kind: { type: "string", const: "artifact_file" }, + filename: { type: "string", minLength: 1, maxLength: 200 }, + contentBase64: { type: "string", minLength: 1 }, + roomId: { type: "string", maxLength: 128 }, + }, + required: ["kind", "filename", "contentBase64"], + additionalProperties: false, + }, + { + type: "object", + properties: { + kind: { type: "string", const: "citation" }, + targetUri: { type: "string", minLength: 1, maxLength: 300 }, + quote: { type: "string", maxLength: 2000 }, + note: { type: "string", maxLength: 2000 }, + }, + required: ["kind", "targetUri"], + additionalProperties: false, + }, + { + type: "object", + properties: { + kind: { type: "string", const: "candidate" }, + candidateType: { type: "string", enum: ["memory", "wiki", "preference"] }, + content: { type: "string", minLength: 1, maxLength: 20_000 }, + evidence: { type: "string", maxLength: 20_000 }, + roomId: { type: "string", maxLength: 128 }, + }, + required: ["kind", "candidateType", "content"], + additionalProperties: false, + }, + ], +}; + +/** 回写三件套:可控文件登记 + 幂等统一提交 + 提交状态查询(PRD 7.4 / A.3 提交键)。 */ +export function createWriteTools( + connection: ResolvedAgentConnection, + service: AgentAccessService, + registerArtifact: (input: { + filename: string; + buffer: Buffer; + roomId: string | null; + sourceKey: string; + }) => Promise<{ fileEntryId: string; contentHash: string; filename: string }>, +): AgentAccessTool[] { + const registerFile: AgentAccessTool = { + name: "everroom_register_file", + title: "登记文件(可控入口)", + description: + "把文件字节提交到可控文件入口:校验类型白名单与 sha256 完整性后入库(sourceKind=agent-generated),返回 fileEntryId 与可打开位置。本机路径登记走桌面连接页的可控入口。", + inputSchema: { + type: "object", + properties: { + filename: { type: "string", minLength: 1, maxLength: 200 }, + contentBase64: { type: "string", minLength: 1 }, + roomId: { type: "string", maxLength: 128 }, + }, + required: ["filename", "contentBase64"], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + capabilities: ["register_files"], + execute: async (args) => { + const filename = String(args.filename ?? ""); + if (!isSupportedUploadFilename(filename)) { + return state("invalid_input", { filename }, "文件类型不在支持清单内;请转换格式或改交文本"); + } + const buffer = Buffer.from(String(args.contentBase64 ?? ""), "base64"); + if (buffer.byteLength === 0 || buffer.byteLength > FILE_BYTES_LIMIT) { + return state("invalid_input", { filename }, "文件为空或超过 20MB 上限"); + } + const roomId = typeof args.roomId === "string" && args.roomId + ? args.roomId + : connection.scopes.writebackRoomId; + if (roomId && !(connection.scopes.roomIds === "all" || connection.scopes.roomIds.includes(roomId))) { + return state("no_permission", undefined, "目标 Room 不在本连接授权范围内"); + } + const imported = await registerArtifact({ + filename, + buffer, + roomId, + sourceKey: `agent-file:${connection.id}:${createHash("sha256").update(buffer).digest("hex")}`, + }); + return state("ok", { + fileEntryId: imported.fileEntryId, + contentHash: imported.contentHash, + filename: imported.filename, + byteSize: buffer.byteLength, + roomId, + deepLink: `everroom://open/file/${imported.fileEntryId}`, + }); + }, + }; + + const submit: AgentAccessTool = { + name: "everroom_submit", + title: "回写提交(幂等)", + description: + "提交会话增量、结果文本、产物文件、来源引用或记忆/Wiki/偏好候选。submissionId 由客户端生成并复用:重试同一 submissionId 不会重复入库(返回 duplicate 与原结果)。候选进入待确认队列,不会未经用户确认写入长期记忆。", + inputSchema: { + type: "object", + properties: { + submissionId: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" }, + items: { type: "array", minItems: 1, maxItems: 50, items: SUBMISSION_ITEM_SCHEMA }, + }, + required: ["submissionId", "items"], + additionalProperties: false, + }, + annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + capabilities: ["write_session", "write_candidates"], + execute: async (args) => { + const submissionId = String(args.submissionId ?? ""); + const items = Array.isArray(args.items) ? (args.items as AgentSubmissionItem[]) : []; + try { + const outcome = await service.submit(connection, submissionId, items); + return state(outcome.status === "duplicate" ? "duplicate" : "ok", outcome); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.startsWith("agent_access_submission_")) { + return state("invalid_input", undefined, message); + } + throw error; + } + }, + }; + + const submitStatus: AgentAccessTool = { + name: "everroom_submit_status", + title: "查询提交结果", + description: "按 submissionId 查询一次回写提交的处理结果与逐项状态。", + inputSchema: { + type: "object", + properties: { + submissionId: { type: "string", pattern: "^[A-Za-z0-9._:-]{1,128}$" }, + }, + required: ["submissionId"], + additionalProperties: false, + }, + annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + capabilities: "always", + execute: async (args) => { + const record = service.getSubmission(connection.id, String(args.submissionId ?? "")); + if (!record) { + return state("empty", undefined, "没有这个提交标识的记录;可检查 submissionId 或重新提交"); + } + return state("ok", record); + }, + }; + + return [registerFile, submit, submitStatus]; +} diff --git a/apps/gateway/src/modules/agent-access/tools.ts b/apps/gateway/src/modules/agent-access/tools.ts new file mode 100644 index 00000000..e9ff6c2f --- /dev/null +++ b/apps/gateway/src/modules/agent-access/tools.ts @@ -0,0 +1,25 @@ +import type { AgentAccessService } from "./service.js"; +import type { ResolvedAgentConnection } from "./types.js"; +import type { AgentAccessTool, AgentAccessToolDeps } from "./tools-read.js"; +import { createReadTools } from "./tools-read.js"; +import { createWriteTools } from "./tools-write.js"; + +/** 对外工具全集 = 读五件套 + 自述 + 回写三件套(host 按 capabilities 过滤可见性)。 */ +export function createAgentAccessTools( + deps: AgentAccessToolDeps, + connection: ResolvedAgentConnection, + service: AgentAccessService, + registerArtifact: (input: { + filename: string; + buffer: Buffer; + roomId: string | null; + sourceKey: string; + }) => Promise<{ fileEntryId: string; contentHash: string; filename: string }>, +): AgentAccessTool[] { + return [ + ...createReadTools(deps, connection), + ...createWriteTools(connection, service, registerArtifact), + ]; +} + +export type { AgentAccessTool, AgentAccessToolDeps } from "./tools-read.js"; diff --git a/apps/gateway/src/modules/agent-access/types.ts b/apps/gateway/src/modules/agent-access/types.ts new file mode 100644 index 00000000..2b5a99ab --- /dev/null +++ b/apps/gateway/src/modules/agent-access/types.ts @@ -0,0 +1,145 @@ +/** + * 外部 Agent 访问(PRD 0.2.0 第 7 章 L2.5 / 附录 A.3)的类型面。 + * + * 设计文档:docs/design/agent-access-mcp-skills.zh-CN.md。 + * 能力集对应 PRD 7.1「选择可访问的 Room/来源、允许读取的类型和回写位置」; + * 提交项五类对应 PRD 7.4;状态语义对应 PRD 7.2「空结果、无权限、来源丢失、 + * 解析失败时使用不同状态」与 1.1「修正后的新读取校验有效版本,未刷新的副本可见」。 + */ + +export const AGENT_ACCESS_CAPABILITIES = [ + "read_metadata", + "search", + "read_documents", + "read_conversations", + "read_files", + "read_rules", + "write_session", + "write_candidates", + "register_files", +] as const; +export type AgentAccessCapability = (typeof AGENT_ACCESS_CAPABILITIES)[number]; + +export const AGENT_ACCESS_TOOL_TARGETS = [ + "claude-code", + "cursor", + "codex", + "cherry-studio", + "openclaw", + "hermes", + "generic", +] as const; +export type AgentAccessToolTarget = (typeof AGENT_ACCESS_TOOL_TARGETS)[number]; + +export interface AgentAccessScopes { + roomIds: string[] | "all"; + capabilities: AgentAccessCapability[]; + writebackRoomId: string | null; +} + +export type AgentConnectionStatus = "active" | "paused" | "revoked"; + +/** 连接页可见的脱敏视图(不含 token 与 tokenHash)。 */ +export interface AgentConnectionView { + id: string; + name: string; + tool: string; + status: AgentConnectionStatus; + scopes: AgentAccessScopes; + tokenPrefix: string; + createdAt: string; + updatedAt: string; + lastSeenAt: string | null; + revokedAt: string | null; + exampleQueryAt: string | null; +} + +/** 鉴权通过后注入工具层的连接上下文。 */ +export interface ResolvedAgentConnection { + id: string; + name: string; + tool: string; + scopes: AgentAccessScopes; +} + +export type AgentAccessStateStatus = + | "ok" + | "empty" + | "no_permission" + | "source_lost" + | "parse_failed" + | "stale" + | "duplicate" + | "pending_review" + | "invalid_input"; + +/** 所有对外工具的统一返回形状(MCP structuredContent,文本块同步镜像 JSON)。 */ +export interface AgentAccessState { + status: AgentAccessStateStatus; + message?: string; + /** no_permission 时指出所需能力,便于 Agent 决定补问还是换来源。 */ + requiredCapability?: AgentAccessCapability; + /** stale 时给出最新版本号(未刷新的副本仍随 data 返回,由调用方标记过期)。 */ + latestVersion?: string; + data?: T; +} + +export interface AgentSessionIncrementMessage { + messageId: string; + role: "user" | "assistant"; + content: string; + occurredAt?: string; +} + +/** PRD 7.4 回写五类。 */ +export type AgentSubmissionItem = + | { + kind: "session_increment"; + platform: string; + threadId: string; + title?: string; + messages: AgentSessionIncrementMessage[]; + } + | { kind: "result_text"; title: string; content: string; roomId?: string } + | { kind: "artifact_file"; filename: string; contentBase64: string; roomId?: string } + | { kind: "citation"; targetUri: string; quote?: string; note?: string } + | { + kind: "candidate"; + candidateType: "memory" | "wiki" | "preference"; + content: string; + evidence?: string; + roomId?: string; + }; + +export interface AgentSubmissionItemResult { + index: number; + kind: string; + status: "saved" | "recorded" | "pending_review" | "rejected" | "duplicate"; + objectId?: string; + objectType?: string; + title?: string; + deepLink?: string; + error?: string; +} + +export interface AgentSubmissionOutcome { + submissionId: string; + clientSubmissionId: string; + status: "accepted" | "duplicate"; + results: AgentSubmissionItemResult[]; +} + +export function isAgentAccessCapability(value: unknown): value is AgentAccessCapability { + return typeof value === "string" && (AGENT_ACCESS_CAPABILITIES as readonly string[]).includes(value); +} + +export function isAgentAccessToolTarget(value: unknown): value is AgentAccessToolTarget { + return typeof value === "string" && (AGENT_ACCESS_TOOL_TARGETS as readonly string[]).includes(value); +} + +/** Room 范围判定(scopes.roomIds 为 "all" 时全放行)。 */ +export function roomInScope(scopes: AgentAccessScopes, roomId: string | null | undefined): boolean { + if (scopes.roomIds === "all") return true; + if (!roomId) return false; + return scopes.roomIds.includes(roomId); +} diff --git a/apps/gateway/src/modules/agent/channel-mcp-host.ts b/apps/gateway/src/modules/agent/channel-mcp-host.ts index ed26a800..3c93e843 100644 --- a/apps/gateway/src/modules/agent/channel-mcp-host.ts +++ b/apps/gateway/src/modules/agent/channel-mcp-host.ts @@ -104,8 +104,8 @@ function keyFor(id: RequestId): string { return `${typeof id}:${String(id)}`; } -/** documents/mcp-host.ts 同款:单请求 JSON-RPC 交换 over POST。 */ -class ExchangeTransport implements Transport { +/** documents/mcp-host.ts 同款:单请求 JSON-RPC 交换 over POST(agent-access 模块复用)。 */ +export class ExchangeTransport implements Transport { onclose?: () => void; onerror?: (error: Error) => void; onmessage?: (message: T) => void; diff --git a/apps/gateway/src/server/auth.ts b/apps/gateway/src/server/auth.ts index 9876d282..a04fcdf6 100644 --- a/apps/gateway/src/server/auth.ts +++ b/apps/gateway/src/server/auth.ts @@ -7,9 +7,12 @@ const authPlugin: FastifyPluginAsync<{ token: string }> = async (app, options) = app.addHook("onRequest", async (request, reply) => { // 渠道 MCP(/v1/mcp/everroom/:token)以 token-in-path 为凭证: // 不把网关全局 token 种进 CLI 子进程环境。 + // 外部 Agent 访问(/v1/mcp/agents)以持久连接 token(Bearer)自鉴权: + // 凭证与范围在 agent_connections 表,由 agent-access 模块校验。 if (request.url.startsWith("/v1/health") || request.url.startsWith("/docs") - || request.url.startsWith("/v1/mcp/everroom/")) { + || request.url.startsWith("/v1/mcp/everroom/") + || request.url.startsWith("/v1/mcp/agents")) { return; } diff --git a/apps/gateway/src/server/create-server.ts b/apps/gateway/src/server/create-server.ts index 94ae34f8..077662a8 100644 --- a/apps/gateway/src/server/create-server.ts +++ b/apps/gateway/src/server/create-server.ts @@ -1,5 +1,6 @@ import { existsSync } from "node:fs"; import { randomUUID } from "node:crypto"; +import { Readable } from "node:stream"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import Fastify, { LogController } from "fastify"; @@ -24,6 +25,11 @@ import { channelToolsFromRuntimeTools, } from "../modules/agent/channel-mcp-host.js"; import { channelMcpRoutes } from "../modules/agent/channel-mcp-routes.js"; +import { AgentAccessMcpHost } from "../modules/agent-access/mcp-host.js"; +import { agentAccessMcpRoutes } from "../modules/agent-access/mcp-routes.js"; +import { agentAccessRoutes } from "../modules/agent-access/routes.js"; +import { AgentAccessService } from "../modules/agent-access/service.js"; +import { createAgentAccessTools } from "../modules/agent-access/tools.js"; import { AgentService } from "../modules/agent/service.js"; import { DocumentEventBroker } from "../modules/documents/event-broker.js"; import { DocumentMcpHost } from "../modules/documents/mcp-host.js"; @@ -523,8 +529,15 @@ export async function createServer(config: GatewayConfig, overrides: ServerOverr // /v1/runtime-config/secrets 是唯一允许真密钥出站的端点:主进程派生托管 // 子进程 env 用(token 鉴权,token 只在主进程);若在此脱敏,子进程会拿 // "[REDACTED]" 当 key 起服务并静默 401。其余响应一律按键名/注册密钥脱敏。 + // agent-access 的连接签发/轮换/安装代写同为有意出站的密钥面(连接 token + // 仅创建时返回一次;三端点都走全局 bearer,install 另需 token 匹配)。 app.addHook("preSerialization", async (request, _reply, payload) => { if (request.routeOptions.url === "/v1/runtime-config/secrets") return payload; + if (request.routeOptions.url === "/v1/agent-access/connections" + || request.routeOptions.url === "/v1/agent-access/connections/:id/token" + || request.routeOptions.url === "/v1/agent-access/connections/:id/install/:target") { + return payload; + } return redactSecrets(payload); }); @@ -1659,6 +1672,54 @@ export async function createServer(config: GatewayConfig, overrides: ServerOverr )); await app.register(documentMcpRoutes(documentMcpHost)); await app.register(channelMcpRoutes(channelMcpHost)); + // 外部 Agent 访问(PRD 0.2.0 第 7 章 L2.5):持久连接 token + 范围过滤的对外 + // MCP 端点。REST(/v1/agent-access/*,连接页)走全局 bearer;MCP 端点 + // /v1/mcp/agents 以连接 token 自鉴权(auth 插件已豁免全局 bearer)。 + // 产物入库走 files 管线(sourceKind=agent-generated,CAS sha256 去重), + // 与渠道 MCP 互不影响:渠道面向 EverRoom 自己拉起的 CLI,这里面向用户 + // 自行安装的外部工具。 + const agentAccessImportArtifact = async (input: { + filename: string; + buffer: Buffer; + roomId: string | null; + sourceKey: string; + }) => { + const result = await filesService.importFileStream({ + sourceKind: "agent-generated", + sourceKey: input.sourceKey, + originalName: input.filename, + stream: Readable.from(input.buffer), + ...(input.roomId ? { roomId: input.roomId, pipelines: { room: true, wiki: false, memory: false } } : {}), + }); + return { + fileEntryId: result.fileEntryId, + contentHash: result.contentHash, + filename: input.filename, + }; + }; + const agentAccessService = new AgentAccessService(db, { + importArtifact: agentAccessImportArtifact, + log: (level, event, fields) => app.log[level](fields ?? {}, event), + }); + const agentAccessMcpHost = new AgentAccessMcpHost( + agentAccessService, + (connection) => createAgentAccessTools( + { + db, + search: searchService, + memory: memoryService, + documents: documentService, + files: filesService, + agent: agentService, + }, + connection, + agentAccessService, + agentAccessImportArtifact, + ), + (level, event, fields) => app.log[level](fields ?? {}, event), + ); + await app.register(agentAccessRoutes(agentAccessService, agentAccessMcpHost, channelMcpBaseUrl)); + await app.register(agentAccessMcpRoutes(agentAccessMcpHost)); await app.register(notificationMcpRoutes(notificationMcpHost)); // 版本概览 worker:保存(document.changed)后异步判定重要性。重要变更 // (标题变更/小节增删/变更块 ≥3/首版)直接生成 AI 概览;不重要变更先把 diff --git a/apps/gateway/tests/agent-access-routes.test.ts b/apps/gateway/tests/agent-access-routes.test.ts new file mode 100644 index 00000000..db89c657 --- /dev/null +++ b/apps/gateway/tests/agent-access-routes.test.ts @@ -0,0 +1,271 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import type { GatewayConfig } from "../src/config.js"; +import { createServer } from "../src/server/create-server.js"; + +/** + * 外部 Agent 访问(PRD 第 7 章)的 HTTP 层测试:真引导整服务器(含 0081 迁移), + * 用 app.inject 实打 /v1/agent-access/* REST 与 /v1/mcp/agents MCP 端点—— + * 覆盖鉴权豁免、TypeBox 校验、自检/示例查询编排与安装代写出口。 + */ + +const temporaryDirectories: string[] = []; + +async function testConfig(): Promise { + const dataDir = await mkdtemp(join(tmpdir(), "nxcore-agent-access-routes-")); + temporaryDirectories.push(dataDir); + return { + host: "127.0.0.1", + port: 0, + dataDir, + databasePath: join(dataDir, "database", "gateway.sqlite"), + migrationsDir: resolve("drizzle"), + runtimeManifestPath: join(dataDir, "runtime", "gateway.json"), + logLevel: "silent", + authToken: "test-token-0123456789", + agentRuntime: "fake", + memory: null, + pi: null, + cursorCompletionPi: null, + litePi: null, + knowledge: null, + ingestFilter: { enabled: false, mode: "observe", confidenceThreshold: 0.7, batchSize: 5, batchDelayMs: 0, exemptSourceKinds: [], toolsEnabled: false, maxToolCalls: 8, rulesFile: "", rulesMaxBytes: 2048, insightEnabled: false, insightIntervalMs: 3_600_000 }, + backgroundPi: null, + transcriptionSummaryPi: null, + asrInputDir: join(dataDir, "recordings"), + webSearch: null, + mcpConfigPath: join(dataDir, 'agent', 'mcp.json'), + asr: null, + }; +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true }))); +}); + +const FULL_CAPS = [ + "read_metadata", "search", "read_documents", "read_conversations", "read_files", + "read_rules", "write_session", "write_candidates", "register_files", +]; +const READ_ONLY_CAPS = FULL_CAPS.filter((cap) => cap.startsWith("read") || cap === "search"); + +function authHeaders(token: string): Record { + return { authorization: `Bearer ${token}` }; +} + +async function createConnection( + app: Awaited>, + gatewayToken: string, + scopes: { capabilities: string[] }, +) { + const response = await app.inject({ + method: "POST", + url: "/v1/agent-access/connections", + headers: { ...authHeaders(gatewayToken), "content-type": "application/json" }, + payload: { + name: "HTTP 测试连接", + tool: "claude-code", + scopes: { roomIds: "all", capabilities: scopes.capabilities, writebackRoomId: null }, + }, + }); + expect(response.statusCode).toBe(201); + return response.json() as { connection: { id: string; status: string }; token: string }; +} + +async function mcp( + app: Awaited>, + connectionToken: string, + method: string, + params: Record = {}, +) { + const response = await app.inject({ + method: "POST", + url: "/v1/mcp/agents", + headers: { ...authHeaders(connectionToken), "content-type": "application/json" }, + payload: { jsonrpc: "2.0", id: Math.floor(Math.random() * 1_000_000), method, params }, + }); + return { statusCode: response.statusCode, body: response.json() as Record }; +} + +describe("agent-access REST(全局 bearer)", () => { + it("未带/错带全局 token 一律 401;合法创建返回 token 一次", async () => { + const config = await testConfig(); + const app = await createServer(config); + const payload = { + name: "x", + tool: "claude-code", + scopes: { roomIds: "all", capabilities: FULL_CAPS, writebackRoomId: null }, + }; + + const anonymous = await app.inject({ + method: "POST", url: "/v1/agent-access/connections", + headers: { "content-type": "application/json" }, payload, + }); + expect(anonymous.statusCode).toBe(401); + + const wrongToken = await app.inject({ + method: "POST", url: "/v1/agent-access/connections", + headers: { ...authHeaders("not-the-gateway-token"), "content-type": "application/json" }, payload, + }); + expect(wrongToken.statusCode).toBe(401); + + const created = await createConnection(app, config.authToken, { capabilities: FULL_CAPS }); + expect(created.token).toMatch(/^evr_/); + expect(created.connection.status).toBe("active"); + + const listed = await app.inject({ + method: "GET", url: "/v1/agent-access/connections", headers: authHeaders(config.authToken), + }); + const body = listed.json() as { connections: Array<{ id: string; tokenPrefix: string }> }; + expect(listed.statusCode).toBe(200); + expect(body.connections).toHaveLength(1); + expect(JSON.stringify(body)).not.toContain(created.token); + }); + + it("能力校验、暂停/恢复、轮换与吊销", async () => { + const config = await testConfig(); + const app = await createServer(config); + + const badCaps = await app.inject({ + method: "POST", url: "/v1/agent-access/connections", + headers: { ...authHeaders(config.authToken), "content-type": "application/json" }, + payload: { name: "x", tool: "claude-code", scopes: { roomIds: "all", capabilities: ["nonsense"], writebackRoomId: null } }, + }); + expect(badCaps.statusCode).toBe(400); + + const badTool = await app.inject({ + method: "POST", url: "/v1/agent-access/connections", + headers: { ...authHeaders(config.authToken), "content-type": "application/json" }, + payload: { name: "x", tool: "warp", scopes: { roomIds: "all", capabilities: FULL_CAPS, writebackRoomId: null } }, + }); + expect(badTool.statusCode).toBe(400); + + const created = await createConnection(app, config.authToken, { capabilities: FULL_CAPS }); + const id = created.connection.id; + + const paused = await app.inject({ + method: "PATCH", url: `/v1/agent-access/connections/${id}`, + headers: { ...authHeaders(config.authToken), "content-type": "application/json" }, + payload: { status: "paused" }, + }); + expect((paused.json() as { connection: { status: string } }).connection.status).toBe("paused"); + + const rotated = await app.inject({ + method: "POST", url: `/v1/agent-access/connections/${id}/token`, headers: authHeaders(config.authToken), + }); + const rotatedToken = (rotated.json() as { token: string }).token; + expect(rotatedToken).toMatch(/^evr_/); + expect(rotatedToken).not.toBe(created.token); + + const revoked = await app.inject({ + method: "DELETE", url: `/v1/agent-access/connections/${id}`, headers: authHeaders(config.authToken), + }); + expect((revoked.json() as { connection: { status: string } }).connection.status).toBe("revoked"); + }); + + it("自检与示例查询可用(空库下各步返回合法状态)", async () => { + const config = await testConfig(); + const app = await createServer(config); + const created = await createConnection(app, config.authToken, { capabilities: FULL_CAPS }); + + const selftest = await app.inject({ + method: "POST", + url: `/v1/agent-access/connections/${created.connection.id}/selftest`, + headers: authHeaders(config.authToken), + }); + expect(selftest.statusCode).toBe(200); + const steps = (selftest.json() as { steps: Record }).steps; + expect(steps.list!.status).toBe("empty"); + expect(steps.search!.status).toBe("empty"); + expect(steps.fetch!.status).toBe("empty"); + expect(["ok", "duplicate"]).toContain(steps.submit!.status); + + const example = await app.inject({ + method: "POST", + url: `/v1/agent-access/connections/${created.connection.id}/example-query`, + headers: { ...authHeaders(config.authToken), "content-type": "application/json" }, + payload: { query: "上线" }, + }); + expect(example.statusCode).toBe(200); + expect((example.json() as { result: { status: string } }).result.status).toBe("empty"); + }); + + it("安装代写:token 匹配校验 + 计划含 MCP 地址与技能文件", async () => { + const config = await testConfig(); + const app = await createServer(config); + const created = await createConnection(app, config.authToken, { capabilities: FULL_CAPS }); + const url = `/v1/agent-access/connections/${created.connection.id}/install/claude-code`; + + const mismatch = await app.inject({ + method: "POST", url, + headers: { ...authHeaders(config.authToken), "content-type": "application/json" }, + payload: { token: "evr_wrong-token" }, + }); + expect(mismatch.statusCode).toBe(403); + + const install = await app.inject({ + method: "POST", url, + headers: { ...authHeaders(config.authToken), "content-type": "application/json" }, + payload: { token: created.token }, + }); + expect(install.statusCode).toBe(200); + const plan = (install.json() as { install: { url: string; skillFiles: Array<{ path: string }>; mcpConfig: string } }).install; + expect(plan.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/v1\/mcp\/agents$/); + expect(plan.skillFiles.map((file) => file.path)).toContain("SKILL.md"); + expect(plan.mcpConfig).toContain(`"Authorization": "Bearer ${created.token}"`); + }); +}); + +describe("agent-access MCP 端点(连接 token 自鉴权)", () => { + it("initialize → tools/list → get_status 全链路;能力过滤生效", async () => { + const config = await testConfig(); + const app = await createServer(config); + const readOnly = await createConnection(app, config.authToken, { capabilities: READ_ONLY_CAPS }); + + const anonymous = await mcp(app, "evr-not-a-connection-token", "tools/list"); + expect(anonymous.statusCode).toBe(401); + + const init = await mcp(app, readOnly.token, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "claude-code", version: "1" }, + }); + expect(init.statusCode).toBe(200); + expect(init.body.result).toMatchObject({ serverInfo: { name: "everroom-agent-access" } }); + + const listed = await mcp(app, readOnly.token, "tools/list"); + const names = (listed.body.result as { tools: Array<{ name: string }> }).tools.map((tool) => tool.name); + expect(names).toContain("everroom_search"); + expect(names).not.toContain("everroom_submit"); + + const status = await mcp(app, readOnly.token, "tools/call", { + name: "everroom_get_status", + arguments: {}, + }); + const structured = (status.body.result as { structuredContent: { status: string; data: { connection: { name: string } } } }).structuredContent; + expect(structured.status).toBe("ok"); + expect(structured.data.connection.name).toBe("HTTP 测试连接"); + }); + + it("暂停的连接立刻失去 MCP 访问", async () => { + const config = await testConfig(); + const app = await createServer(config); + const created = await createConnection(app, config.authToken, { capabilities: FULL_CAPS }); + const id = created.connection.id; + + const before = await mcp(app, created.token, "tools/list"); + expect(before.statusCode).toBe(200); + + await app.inject({ + method: "PATCH", url: `/v1/agent-access/connections/${id}`, + headers: { ...authHeaders(config.authToken), "content-type": "application/json" }, + payload: { status: "paused" }, + }); + + const after = await mcp(app, created.token, "tools/list"); + expect(after.statusCode).toBe(401); + expect((after.body.error as { code: number }).code).toBe(-32001); + }); +}); diff --git a/apps/gateway/tests/agent-access.test.ts b/apps/gateway/tests/agent-access.test.ts new file mode 100644 index 00000000..32502b91 --- /dev/null +++ b/apps/gateway/tests/agent-access.test.ts @@ -0,0 +1,440 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createDatabase, type DatabaseClient } from "../src/infrastructure/database/client.js"; +import { contextRooms, roomOverviews } from "../src/infrastructure/database/schema.js"; +import { AgentAccessMcpHost } from "../src/modules/agent-access/mcp-host.js"; +import { AgentAccessService } from "../src/modules/agent-access/service.js"; +import { createAgentAccessTools } from "../src/modules/agent-access/tools.js"; +import type { AgentAccessToolDeps } from "../src/modules/agent-access/tools-read.js"; + +/** + * 外部 Agent 访问(PRD 第 7 章)阶段 1 覆盖: + * 连接生命周期、范围过滤(工具可见性 + 数据行过滤)、MCP 交换(含重启自愈)、 + * 提交幂等与状态语义。服务依赖全部为窄接口假件(开发规范 §5:不依赖外部服务)。 + */ + +interface ArtifactCall { + filename: string; + roomId: string | null; + sourceKey: string; + byteSize: number; +} + +function setup() { + const dataDir = mkdtempSync(join(tmpdir(), "agent-access-test-")); + const client: DatabaseClient = createDatabase(join(dataDir, "gateway.sqlite"), resolve("drizzle")); + + client.db.insert(contextRooms).values([ + { id: "room-a", title: "Room A", kind: "topic", data: {} }, + { id: "room-b", title: "Room B", kind: "topic", data: {} }, + ]).run(); + client.db.insert(roomOverviews).values([ + { roomId: "room-a", baseProjection: {}, projection: {}, generatedAt: new Date(), updatedAt: new Date(), revision: 3 }, + { roomId: "room-b", baseProjection: {}, projection: {}, generatedAt: new Date(), updatedAt: new Date(), revision: 1 }, + ]).run(); + + const artifactCalls: ArtifactCall[] = []; + const importArtifact = async (input: { + filename: string; + buffer: Buffer; + roomId: string | null; + sourceKey: string; + }) => { + artifactCalls.push({ + filename: input.filename, + roomId: input.roomId, + sourceKey: input.sourceKey, + byteSize: input.buffer.byteLength, + }); + const seq = artifactCalls.length; + return { fileEntryId: `file-${seq}`, contentHash: `sha-${seq}`, filename: input.filename }; + }; + + const service = new AgentAccessService(client.db, { importArtifact }); + + // 搜索假件:room-b 过滤时有一条会话命中,其他范围返回空(验证 roomIds 透传)。 + const deps: AgentAccessToolDeps = { + db: client.db, + search: { + search: (query, filters, _limit, _offset) => { + const allowed = !filters.roomIds || filters.roomIds.includes("room-b"); + if (!allowed || query !== "讨论") { + return { items: [], total: 0 }; + } + return { + items: [{ + itemType: "message", + refId: "m-1", + title: "旧讨论", + snippet: "关于上线的讨论", + roomIds: ["room-b"], + versionLabel: null, + ingestedAt: "2026-10-01T00:00:00.000Z", + sessionId: "s-1", + status: "indexed", + }, { + itemType: "room", + refId: "room-b", + title: "Room B", + snippet: "Room B", + roomIds: ["room-b"], + versionLabel: "rev1", + ingestedAt: "2026-10-01T00:00:00.000Z", + sessionId: null, + status: "indexed", + }], + total: 2, + }; + }, + status: () => ({ indexed: 1 }), + }, + memory: null, + documents: { + get: (id) => id === "doc-1" + ? { id: "doc-1", roomId: "room-b", title: "文档一", version: 2, deletedAt: null } + : null, + readDocumentForAgent: (id) => ({ + document: { id, roomId: "room-b", title: "文档一", version: 2 }, + blocks: [], + markdown: "# 文档一\n正文", + }), + }, + files: { + markdownOf: (id) => (id === "f-1" ? "# 文件一" : null), + contentOf: async (id) => (id === "f-1" + ? { buffer: Buffer.from("# 文件一"), mime: "text/markdown", filename: "one.md" } + : null), + }, + agent: { + getSnapshot: (id) => (id === "s-1" + ? { + session: { id: "s-1", roomId: "room-b", title: "旧会话" }, + messages: [{ id: "m-1", role: "user", content: "上线时间定了吗", createdAt: "2026-10-01T00:00:00.000Z" }], + } + : null), + }, + }; + + const makeHost = () => new AgentAccessMcpHost( + service, + (connection) => createAgentAccessTools(deps, connection, service, importArtifact), + ); + + return { client, service, deps, makeHost, artifactCalls, dataDir }; +} + +const ALL_CAPS = [ + "read_metadata", "search", "read_documents", "read_conversations", "read_files", + "read_rules", "write_session", "write_candidates", "register_files", +] as const; +const READ_ONLY_CAPS = ALL_CAPS.filter((cap) => cap.startsWith("read") || cap === "search"); + +let nextRpcId = 0; + +async function rpc(host: AgentAccessMcpHost, token: string, method: string, params: Record = {}) { + const id = ++nextRpcId; + return await host.exchangeTrusted(`Bearer ${token}`, { jsonrpc: "2.0", id, method, params }); +} + +function resultOf(messages: Array>): Record { + const first = messages[0] as { result?: Record } | undefined; + if (!first?.result) throw new Error("missing result"); + return first.result; +} + +describe("AgentAccessService 连接生命周期", () => { + it("签发/暂停/恢复/轮换/吊销,列表脱敏", () => { + const { client, service, dataDir } = setup(); + try { + const created = service.createConnection({ + name: "Claude Code 笔记本", + tool: "claude-code", + scopes: { roomIds: ["room-a"], capabilities: [...ALL_CAPS], writebackRoomId: "room-a" }, + }); + expect(created.token).toMatch(/^evr_[A-Za-z0-9_-]{20,}$/); + expect(created.connection.status).toBe("active"); + expect(JSON.stringify(service.listConnections())).not.toContain(created.token); + + expect(service.verifyBearer(created.token)?.id).toBe(created.connection.id); + expect(service.verifyBearer("evr_not-a-real-token")).toBeNull(); + + service.updateConnection(created.connection.id, { status: "paused" }); + expect(service.verifyBearer(created.token)).toBeNull(); + + service.updateConnection(created.connection.id, { status: "active" }); + expect(service.verifyBearer(created.token)?.id).toBe(created.connection.id); + + const rotated = service.rotateToken(created.connection.id)!; + expect(service.verifyBearer(created.token)).toBeNull(); + expect(service.verifyBearer(rotated.token)?.id).toBe(created.connection.id); + expect(service.verifyTokenMatch(created.connection.id, rotated.token)).toBe(true); + + const revoked = service.revokeConnection(created.connection.id)!; + expect(revoked.status).toBe("revoked"); + expect(service.verifyBearer(rotated.token)).toBeNull(); + expect(service.updateConnection(created.connection.id, { status: "active" })).toBeNull(); + } finally { + client.sqlite.close(); + rmSync(dataDir, { recursive: true, force: true }); + } + }); +}); + +describe("AgentAccessMcpHost 范围与状态语义", () => { + it("initialize → tools/list 按能力过滤;无写能力看不到 everroom_submit", async () => { + const { client, service, makeHost, dataDir } = setup(); + const host = makeHost(); + try { + const scoped = service.createConnection({ + name: "只读", + tool: "cursor", + scopes: { roomIds: ["room-a"], capabilities: [...READ_ONLY_CAPS], writebackRoomId: null }, + }); + const init = await rpc(host, scoped.token, "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "cursor", version: "1" }, + }); + expect(resultOf(init)).toMatchObject({ serverInfo: { name: "everroom-agent-access" } }); + + const listed = resultOf(await rpc(host, scoped.token, "tools/list")) as { + tools: Array<{ name: string }>; + }; + const names = listed.tools.map((tool) => tool.name); + expect(names).toContain("everroom_search"); + expect(names).toContain("everroom_fetch"); + expect(names).not.toContain("everroom_submit"); + expect(names).not.toContain("everroom_register_file"); + + const full = service.createConnection({ + name: "全权", + tool: "codex", + scopes: { roomIds: "all", capabilities: [...ALL_CAPS], writebackRoomId: "room-a" }, + }); + const fullNames = (resultOf(await rpc(host, full.token, "tools/list")) as { + tools: Array<{ name: string }>; + }).tools.map((tool) => tool.name); + expect(fullNames).toContain("everroom_submit"); + await host.close(); + } finally { + client.sqlite.close(); + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("数据行过滤:list_rooms 只见授权 Room;越权 fetch 返回 no_permission", async () => { + const { client, service, makeHost, dataDir } = setup(); + const host = makeHost(); + try { + const scoped = service.createConnection({ + name: "A 房间", + tool: "claude-code", + scopes: { roomIds: ["room-a"], capabilities: [...ALL_CAPS], writebackRoomId: "room-a" }, + }); + await rpc(host, scoped.token, "initialize", {}); + + const rooms = resultOf(await rpc(host, scoped.token, "tools/call", { + name: "everroom_list_rooms", + arguments: {}, + })) as { structuredContent: { status: string; data: { rooms: Array<{ roomId: string; revision: number }> } } }; + expect(rooms.structuredContent.status).toBe("ok"); + expect(rooms.structuredContent.data.rooms.map((room) => room.roomId)).toEqual(["room-a"]); + expect(rooms.structuredContent.data.rooms[0]!.revision).toBe(3); + + const deniedDoc = resultOf(await rpc(host, scoped.token, "tools/call", { + name: "everroom_fetch", + arguments: { uri: "everroom://documents/doc-1" }, + })) as { structuredContent: { status: string } }; + expect(deniedDoc.structuredContent.status).toBe("no_permission"); + + const deniedSearch = resultOf(await rpc(host, scoped.token, "tools/call", { + name: "everroom_search", + arguments: { query: "讨论" }, + })) as { structuredContent: { status: string } }; + expect(deniedSearch.structuredContent.status).toBe("empty"); + await host.close(); + } finally { + client.sqlite.close(); + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("搜索命中带会话/消息 ID;精读支持 basedOnVersion 的 stale 语义", async () => { + const { client, service, makeHost, dataDir } = setup(); + const host = makeHost(); + try { + const full = service.createConnection({ + name: "全库", + tool: "generic", + scopes: { roomIds: "all", capabilities: [...ALL_CAPS], writebackRoomId: null }, + }); + await rpc(host, full.token, "initialize", {}); + + const search = resultOf(await rpc(host, full.token, "tools/call", { + name: "everroom_search", + arguments: { query: "讨论" }, + })) as { structuredContent: { data: { hits: Array> } } }; + const hit = search.structuredContent.data.hits.find((item) => item.kind === "conversation"); + expect(hit).toMatchObject({ conversationId: "s-1", messageId: "m-1" }); + // 搜索命中的 uri 必须能被 everroom_fetch 解析(rooms/documents/files 复数段)。 + const roomHit = search.structuredContent.data.hits.find((item) => item.kind === "room"); + expect(roomHit?.uri).toBe("everroom://rooms/room-b"); + + const fresh = resultOf(await rpc(host, full.token, "tools/call", { + name: "everroom_fetch", + arguments: { uri: "everroom://conversations/s-1", basedOnVersion: "2026-10-01T00:00:00.000Z" }, + })) as { structuredContent: { status: string } }; + expect(fresh.structuredContent.status).toBe("ok"); + + const stale = resultOf(await rpc(host, full.token, "tools/call", { + name: "everroom_fetch", + arguments: { uri: "everroom://conversations/s-1", basedOnVersion: "1999-01-01T00:00:00.000Z" }, + })) as { structuredContent: { status: string; latestVersion: string } }; + expect(stale.structuredContent.status).toBe("stale"); + expect(stale.structuredContent.latestVersion).toBe("2026-10-01T00:00:00.000Z"); + + const lost = resultOf(await rpc(host, full.token, "tools/call", { + name: "everroom_fetch", + arguments: { uri: "everroom://conversations/s-nope" }, + })) as { structuredContent: { status: string } }; + expect(lost.structuredContent.status).toBe("source_lost"); + + const fileRead = resultOf(await rpc(host, full.token, "tools/call", { + name: "everroom_read_file", + arguments: { fileId: "f-1" }, + })) as { structuredContent: { status: string; data: { version: string; markdown: string } } }; + expect(fileRead.structuredContent.status).toBe("ok"); + expect(fileRead.structuredContent.data.version).toMatch(/^[a-f0-9]{64}$/); + expect(fileRead.structuredContent.data.markdown).toContain("文件一"); + await host.close(); + } finally { + client.sqlite.close(); + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("无效 token 拒绝;重启自愈(新 host 实例无需重新 initialize)", async () => { + const { client, service, makeHost, dataDir } = setup(); + try { + const full = service.createConnection({ + name: "重启", + tool: "claude-code", + scopes: { roomIds: "all", capabilities: [...ALL_CAPS], writebackRoomId: null }, + }); + await expect(rpc(makeHost(), "evr_broken", "tools/list")) + .rejects.toThrow("MCP_CONNECTION_INVALID"); + await expect(rpc(makeHost(), "evr_broken", "tools/call", { + name: "everroom_get_status", + arguments: {}, + })).rejects.toThrow("MCP_CONNECTION_INVALID"); + + const firstHost = makeHost(); + await rpc(firstHost, full.token, "initialize", {}); + await firstHost.close(); + + const restarted = makeHost(); + const listed = resultOf(await rpc(restarted, full.token, "tools/list")) as { + tools: Array<{ name: string }>; + }; + expect(listed.tools.length).toBeGreaterThan(0); + await restarted.close(); + } finally { + client.sqlite.close(); + rmSync(dataDir, { recursive: true, force: true }); + } + }); +}); + +describe("提交幂等(PRD 7.4 重试复用同一提交标识)", () => { + it("五类条目各自落位;重放返回 duplicate 且不再入库", async () => { + const { client, service, dataDir, artifactCalls } = setup(); + try { + const created = service.createConnection({ + name: "回写", + tool: "codex", + scopes: { roomIds: ["room-a"], capabilities: [...ALL_CAPS], writebackRoomId: "room-a" }, + }); + const connection = service.verifyBearer(created.token)!; + const outcome = await service.submit(connection, "task-42-run-1", [ + { kind: "session_increment", platform: "codex", threadId: "t-1", messages: [{ messageId: "m-1", role: "user", content: "做了调研" }] }, + { kind: "result_text", title: "调研结论", content: "# 结论\n……" }, + { kind: "artifact_file", filename: "report.md", contentBase64: Buffer.from("# 报告").toString("base64") }, + { kind: "citation", targetUri: "everroom://documents/doc-1", quote: "上线时间" }, + { kind: "candidate", candidateType: "memory", content: "用户偏好结论先行" }, + ]); + expect(outcome.status).toBe("accepted"); + expect(outcome.results.map((item) => item.status)).toEqual([ + "recorded", "saved", "saved", "recorded", "pending_review", + ]); + expect(artifactCalls).toHaveLength(2); // result_text + artifact_file + + const replay = await service.submit(connection, "task-42-run-1", [ + { kind: "citation", targetUri: "everroom://self" }, + ]); + expect(replay.status).toBe("duplicate"); + expect(replay.results.map((item) => item.status)).toEqual([ + "recorded", "saved", "saved", "recorded", "pending_review", + ]); + expect(artifactCalls).toHaveLength(2); + + const record = service.getSubmission(created.connection.id, "task-42-run-1")!; + expect(record.duplicateCount).toBe(1); + expect(record.status).toBe("completed"); + + await expect(service.submit(connection, "bad id!", [])).rejects.toThrow("agent_access_submission"); + await expect( + service.submit(connection, "ok-id", [{ kind: "candidate", candidateType: "memory", content: " " }]), + ).resolves.toMatchObject({ status: "accepted", results: [{ status: "rejected" }] }); + } finally { + client.sqlite.close(); + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("MCP 端 everroom_submit 幂等 + submit_status 可查;事件落 agent_access_events", async () => { + const { client, service, makeHost, dataDir } = setup(); + const host = makeHost(); + try { + const full = service.createConnection({ + name: "提交", + tool: "claude-code", + scopes: { roomIds: "all", capabilities: [...ALL_CAPS], writebackRoomId: "room-a" }, + }); + await rpc(host, full.token, "initialize", {}); + + const call = async (args: Record) => resultOf(await rpc(host, full.token, "tools/call", { + name: "everroom_submit", + arguments: args, + })) as { structuredContent: { status: string; data: { results: Array<{ status: string }> } } }; + + const first = await call({ + submissionId: "sub-1", + items: [{ kind: "candidate", candidateType: "preference", content: "PPT 浅色少字" }], + }); + expect(first.structuredContent.status).toBe("ok"); + expect(first.structuredContent.data.results[0]!.status).toBe("pending_review"); + + const replay = await call({ + submissionId: "sub-1", + items: [{ kind: "candidate", candidateType: "preference", content: "PPT 浅色少字" }], + }); + expect(replay.structuredContent.status).toBe("duplicate"); + + const status = resultOf(await rpc(host, full.token, "tools/call", { + name: "everroom_submit_status", + arguments: { submissionId: "sub-1" }, + })) as { structuredContent: { status: string } }; + expect(status.structuredContent.status).toBe("ok"); + + const events = service.listEvents(full.connection.id); + expect(events.some((event) => event.kind === "connect")).toBe(true); + expect(events.filter((event) => event.kind === "call" && event.tool === "everroom_submit").length).toBe(2); + await host.close(); + } finally { + client.sqlite.close(); + rmSync(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/docs/design/agent-access-mcp-skills.zh-CN.md b/docs/design/agent-access-mcp-skills.zh-CN.md new file mode 100644 index 00000000..66b3af26 --- /dev/null +++ b/docs/design/agent-access-mcp-skills.zh-CN.md @@ -0,0 +1,145 @@ +# 外部 Agent 访问(MCP + Skills)设计与影响评估 + +- 对应 PRD:《EverRoom 0.2.0 大版本更新 PRD》第 7 章(L2.5 外部 Agent 接入)+ 附录 A.3 外部读取与回写协议 + 10.2「Skills」发布门槛。 +- 前置调研:`docs/notes/everroom-agent-access-research.md`(2026-10-16,四路调查合成)。 +- 状态:**阶段 1(网关后端核心)与阶段 2(桌面端固定端口 + Agent 连接页 + 安装落盘)均已实施并通过测试**(2026-10-17)。阶段 1:3 张表 + 迁移 0081、agent-access 模块(连接/提交/事件服务、9 工具面、MCP host、连接页 REST、安装代写生成)、`/v1/mcp/agents` 端点与 auth 豁免;`tests/agent-access.test.ts` 7 用例全绿。阶段 2:`GatewaySupervisor.portProvider` 固定端口(`dataDir/agent-access-settings.json`)、`AgentAccessGatewayBridge` + IPC 通道组 + preload `agentAccess` API + shared DTO、AgentConnectionsPage(列表/新建含 Room 多选与能力勾选/暂停恢复/换 token/自检/示例查询/安装/调试面板/固定端口设置卡)、`AgentAccessInstaller` 落盘(Claude Code/Cursor 自动合并配置、Codex 手动片段、先备份、损坏不写);desktop typecheck + electron-vite build + 11 项相关测试通过。剩余:真机端到端验证(见 §8)。 +- 本文档是大任务门禁(开发规范 §6)的影响评估 + 接口设计,开工已获用户确认(2026-10-17「开工」)。 + +## 1. 目标与非目标 + +**目标(阶段 1,本轮)**:网关侧 P0——连接凭证 + 范围模型 + 对外 MCP 端点 + 五种读取能力 + 幂等回写提交 + 连接页所需 REST(含自检/示例查询/安装代写内容生成)+ 测试。 + +**非目标(后续阶段)**: +- 桌面「Agent 连接页」UI 与配置文件代写落盘(阶段 2;REST 已就绪)。 +- 网关固定端口 / 桌面 supervisor 改造(阶段 2;当前 URL 取 listen 后实际绑定地址,重启端口变化需重装配置,见 §7 风险)。 +- 云端 OAuth 2.1 资源服务器(P1)。 +- 候选回写接入第 6 章三路治理管线(记忆团队交付问题卡/治理引擎后的集成点,见 §5.4)。 +- 会话增量并入 external_agent_threads 同步链(两链合并,阶段 2;当前先落幂等提交存储,见 §5.4)。 +- `resources/read`、resource_link 块、MCP Tasks 扩展、Skills-over-MCP(P1+)。 + +## 2. 架构红线自查(开发规范 §9) + +- 本次新增端点:`/v1/mcp/agents`(对外 MCP,连接 token 鉴权,`server/auth.ts` 豁免全局 bearer——与既有 `/v1/mcp/everroom/` 同款豁免模式)+ `/v1/agent-access/*`(REST,走既有全局 bearer,仅桌面调用)。**不新增监听端口、不新增进程**;挂在现有 Fastify 网关上。 +- 存储:复用现有 SQLite(gateway.sqlite)+ drizzle,新增 3 张表(§4)。不引入新依赖(MCP SDK 复用仓库既有 `@modelcontextprotocol/sdk ^1.30`,传输复用 channel-mcp-host 的 `ExchangeTransport`,仅将其导出共享——不复制代码)。 +- 结论:对照红线无违规;无破坏性变更(全部为增量端点/增量表)。 + +## 3. 涉及模块清单 + +| 文件 | 变更 | +|---|---| +| `apps/gateway/src/infrastructure/database/schema.ts` | 新增 agent_connections / agent_submissions / agent_access_events 三表 | +| `apps/gateway/drizzle/0081_agent_access.sql` | drizzle-kit 生成迁移 | +| `apps/gateway/src/modules/agent-access/`(新模块) | types.ts / service.ts / tools.ts / install.ts / mcp-host.ts / routes.ts / mcp-routes.ts | +| `apps/gateway/src/server/auth.ts` | `/v1/mcp/agents` 豁免全局 bearer(连接 token 自鉴权) | +| `apps/gateway/src/modules/agent/channel-mcp-host.ts` | `ExchangeTransport` 加 export(复用不复制) | +| `apps/gateway/src/server/create-server.ts` | 组装 + 注册(channelMcpRoutes 注册点之后) | +| `apps/gateway/tests/agent-access.test.ts` | 新增测试 | +| `docs/notes/everroom-agent-access-research.md` | 调研报告从仓库根迁入(规范 §4) | + +回滚:纯增量,revert 工作区文件即可;迁移为 CREATE TABLE 幂等 additive(client.ts 的 runAdditiveMigrationIdempotently 语义)。 + +## 4. 数据模型(新表) + +``` +agent_connections + id, name, tool(claude-code|cursor|codex|cherry-studio|openclaw|hermes|generic), + tokenHash(sha256, unique), tokenPrefix(展示用前 8 位), + scopes JSON { roomIds: string[]|"all", + capabilities: AgentAccessCapability[], + writebackRoomId: string|null }, + status(active|paused|revoked), lastSeenAt, createdAt/updatedAt, revokedAt + +agent_submissions + id(uuid), connectionId(FK cascade), clientSubmissionId(客户端提交键), + items JSON(原始载荷), status(received|completed|partial_failed|rejected), + results JSON(逐项结果), duplicateCount, + UNIQUE(connectionId, clientSubmissionId) ← 幂等键(PRD A.3「提交键」) + +agent_access_events(7.5 调试页数据源;每连接保留最近 200 条,插入时裁剪) + id, connectionId(FK cascade), kind(call|connect|submit), tool, status, + durationMs, detail JSON, createdAt +``` + +能力集 `AgentAccessCapability`(PRD 7.1「允许读取的类型和回写位置」): +`read_metadata / search / read_documents / read_conversations / read_files / read_rules / write_session / write_candidates / register_files`。 + +token 形态:`evr_` + 32B base64url;**仅创建/轮换时返回一次**,库存 sha256。 + +## 5. 对外接口 + +### 5.1 MCP 端点 `POST /v1/mcp/agents` + +- 传输:与 `/v1/mcp/everroom/:token` 同款单请求 JSON-RPC 交换(SDK `Server` + 共享 `ExchangeTransport`),兼容现役 Claude Code / Codex 客户端(channel MCP 已实证)。 +- 鉴权:`Authorization: Bearer <连接token>`;无效/暂停/吊销 → 404 + JSON-RPC `-32001`(与渠道 MCP 同错误形状)。 +- 会话自愈:网关重启后首个非 initialize 请求自动重建会话(渠道版会报错;连接是持久的,这里必须自愈)。 +- `tools/list` 按连接 capabilities 过滤;每个工具带 `title` + `readOnlyHint/destructiveHint/idempotentHint`(Anthropic 评审硬要求)。 + +### 5.2 工具面(A.3 六组能力 → 9 个工具) + +| 工具 | 能力 | 说明 | +|---|---|---| +| `everroom_list_rooms` | read_metadata | Room 元信息 + revision + deepLink | +| `everroom_search` | search | FTS5(room/conversation/document/file/export)+ MemoryCore 原子记忆;每命中带稳定对象 ID、版本、`everroom://` URI、会话命中带 conversationId+messageId(A.3 硬要求) | +| `everroom_fetch` | 按目标分(read_conversations/read_documents/read_files/read_metadata/search) | 按 `everroom://` URI 精读;支持 `basedOnVersion` → `stale` 状态(R8:未刷新副本可见,内容照给 + 标记过期) | +| `everroom_read_file` | read_files | 源文件正文/markdown + sha256 | +| `everroom_get_rules` | read_rules | P0 返回写作风格/整理偏好底料;6.7 统一技能库上线后换全量(PRD 7.2 语义) | +| `everroom_get_status` | 常驻 | 连接信息 + 各服务可用性(自描述降级,防 Agent 盲重试) | +| `everroom_register_file` | register_files | 字节提交(类型白名单 + sha256 完整性);本机路径登记待阶段 2 可控入口 | +| `everroom_submit` | write_session / write_candidates | 统一提交,`submissionId` 客户端生成,服务端 `(connectionId, submissionId)` 幂等;逐项返回 status/objectId/deepLink | +| `everroom_submit_status` | 常驻 | 按 submissionId 查处理结果 | + +状态语义(`structuredContent.status`):`ok / empty / no_permission / source_lost / parse_failed / stale / duplicate / pending_review / invalid_input`(PRD R4 + R8;`pending_refresh` 待 DeliveryReceipt 集成)。 + +URI 空间:`everroom://rooms/{id}(/overview)`、`everroom://conversations/{sessionId}`、`everroom://documents/{id}`、`everroom://files/{id}`、`everroom://memory/{id}`;人类可打开位置 `everroom://open/{kind}/{id}`(桌面协议注册随阶段 2 连接页落地)。 + +### 5.3 提交项类型(PRD 7.4 五类) + +| kind | 阶段 1 处理 | 返回 | +|---|---|---| +| session_increment | 幂等落 `agent_submissions`(含 平台+线程+消息 ID+内容 去重键);并入 external_agent_threads 同步链是阶段 2 集成点(两链合并) | recorded | +| result_text | 经 files 管线存为 markdown 产物(sourceKind=agent-generated,CAS sha256 去重)→ writebackRoomId | saved + fileId | +| artifact_file | 类型白名单校验 + files 管线入库 | saved / rejected(unsupported_file_type) | +| citation | 记录于提交存储(引用链证据) | recorded | +| candidate(memory/wiki/preference) | `pending_review`;三路治理(6.3.6 自动完成/按规则/问题卡)接入前的暂存态 | pending_review | + +### 5.4 与两链合并(PRD 7.3/9.3)的衔接 + +外部回写链(Skill 调用 → `everroom_submit`)与后台同步链(Codex sessions 解析等 data-migrations)必须按「平台+原会话/消息 ID+版本」合并到同一对象。阶段 1 的提交存储已带全部合并键;阶段 2 在同步链读取侧做合并(external_agent_threads 需要 dataMigrationSources + memorySessionId 语义,属导入管线领地,不在本阶段擅写)。 + +### 5.5 连接页 REST(全局 bearer,桌面专用) + +``` +POST /v1/agent-access/connections 创建(返回 token 一次) +GET /v1/agent-access/connections 列表(脱敏) +PATCH /v1/agent-access/connections/:id 改名/改范围/暂停/恢复 +DELETE /v1/agent-access/connections/:id 吊销 +POST /v1/agent-access/connections/:id/token 轮换 token +GET /v1/agent-access/connections/:id/submissions 提交记录(调试页) +GET /v1/agent-access/connections/:id/events 最近调用(调试页) +POST /v1/agent-access/connections/:id/selftest 搜索/精读/回写三项自检(PRD 7.1.3) +POST /v1/agent-access/connections/:id/example-query 示例查询+出处(PRD 7.1.4) +GET /v1/agent-access/connections/:id/install/:target 安装代写内容(mcp 配置片段 + skill 文件内容;target=claude-code|cursor|codex|cherry-studio|generic) +``` + +Skill 遵循 agentskills.io 六字段规范(name=everroom-agent),由网关生成内容、桌面阶段 2 负责写入各工具目录(`.claude/skills`、`.cursor/skills`、`.codex/skills` 等)与配置文件。 + +## 6. 测试计划 + +- `tests/agent-access.test.ts`:连接生命周期(签发/暂停/恢复/轮换/吊销/脱敏)、范围过滤(list_rooms/search/fetch 的 no_permission)、MCP 交换(initialize/list 按能力过滤/结构化状态/无效 token/重启自愈)、提交幂等(重放返回 duplicate、逐项状态)。 +- 只跑本模块测试 + typecheck(开发规范 §5 相关性质则)。 +- 已知预存失败不触碰(gateway 基线见记忆:bundled-agent 等)。 + +## 7. 风险与待拍板 + +1. **端口随机**(阶段 2 解决前):安装片段写 listen 后实际地址;网关重启端口变化 → 外部工具配置失效需重装。阶段 2 方案:桌面持久化固定端口设置并以 `--port` 传入。 +2. **候选治理**:三路治理引擎(6.3.6)交付前,候选停留在 pending_review,不自动写记忆——宁可保守不可重复污染(PRD G3)。 +3. **SDK 1.x**:沿用仓库既有 `@modelcontextprotocol/sdk ^1.30`(channel MCP 已实证兼容现役 CLI),v2 双时代升级独立成项再做,不在本阶段混入(开发规范 §8 功能/重构分离)。 +4. **无 CORS**:CLI 场景无碍;浏览器宿主(Cherry Studio 桌面版为 Electron 非浏览器)待验证。 + +## 8. 真机端到端验证清单(待做) + +1. 桌面启动 → Agent 连接页 → 设置固定端口(如 8790)→ 重启网关 → 端口生效。 +2. 新建 claude-code 连接(全部 Room + 读能力 + 回写)→ 复制 token → 安装 → 检查 `~/.claude/skills/everroom-agent/` 与 `~/.claude.json` 的 `mcpServers.everroom`。 +3. 终端起 Claude Code(项目目录)→ `/mcp` 查看 everroom 服务器 → 让它 `everroom_search` 一次 → 命中带 URI/版本 → `everroom_fetch` 精读 → `everroom_submit` 提交一条候选 → 连接页调试面板看到提交(pending_review)。 +4. 重启 EverRoom(网关随固定端口重生)→ Claude Code 直接再调一次工具(连接持久 + 会话自愈验证)。 +5. PRD 10.2 门槛用例:G1 搜索→精读原会话与源文件;G2 双连接范围隔离;G3 同 submissionId 重放 duplicate。 diff --git a/docs/notes/everroom-agent-access-research.md b/docs/notes/everroom-agent-access-research.md new file mode 100644 index 00000000..e4e13124 --- /dev/null +++ b/docs/notes/everroom-agent-access-research.md @@ -0,0 +1,283 @@ +# EverRoom 外部 Agent 访问调研:MCP + Skills 方案 + +> 调研日期:2026-10-16。四路并行调查合成:网关代码现状 / 领域数据模型 / MCP+Skills 官方规范与生态 / 竞品架构。 +> +> PRD 目标(1.1):「在原来的 AI 工具中继续工作,通过 Skills 按需获取 EverRoom 的积累;修正后的新读取校验有效版本,未刷新的副本可见。」 +> 发布门槛(10.2):「搜索能精读原对话和源文件;范围控制有效;主动回写与后台同步无重复。」 + +## 0. 结论速览 + +1. **不用从零做。** 网关里已有成型 MCP 基建:`documents/mcp-host.ts`(MCP 服务器 `everroom-context-room`,25 个 `context_room_*` 读写工具)和对外通道 `POST /v1/mcp/everroom/:token`(token 在路径、24h TTL、绑 Room 范围,含 `memory_search / conversation_search / wiki_search / wiki_read / room_context_get`)。它今天只被注入 EverRoom 自己经 ACP 拉起的 CLI;外部 Agent 访问 = 把这套工具面改造成**可对第三方签发、可范围控制、可持久化**的正式端点。 +2. **形态:远程 Streamable HTTP MCP 挂在网关上,不做 stdio 代理。** 本地 127.0.0.1 与云端多租户同一形态。用官方 TS SDK v2 的 `createMcpHandler`——一个端点同时兼容 2025 握手时代与 2026-07-28 无状态时代(客户端舰队混编是现实,SDK 自动协商)。 +3. **鉴权分两档:** 本地桌面 = 持久化连接 token(连接页签发/暂停/吊销,HTTP header 携带);云端 SaaS = OAuth 2.1 资源服务器全套(RFC 9728 + DCR/CIMD + PKCE,scopes 映射 Room 与读写能力)。 +4. **Skill 是方法层,MCP 是能力层,Plugin 是分发层**(Anthropic 官方三件套提法)。Skill 遵循 agentskills.io 开放标准(六字段 frontmatter),同一份 SKILL.md 在 Claude Code / Cursor / Codex / Gemini CLI / Windsurf / Copilot 等 46 个客户端可用;Claude 生态再用 plugin 把 skill + `.mcp.json` 打包成一次安装。连接页可为各工具**代写配置文件**完成安装。 +5. **可领先竞品的长板:** ① 客户端幂等提交(`submission_id`)——Linear/Notion/Slack/GitHub 都没做,PRD 明确要求,且我们已有 `agent_runs.idempotencyKey` 先例;② 读结果统一携带版本/时间戳/内容哈希,让 Agent 识别「未刷新的副本」(Notion 是唯一做得好的,`page_last_edited_at` 模式)。 +6. **主要工程缺口:** 网关端口每次启动随机(第三方拿不到稳定地址)、无第三方凭证持久化与吊销、无范围模型、无通用外部提交幂等存储、会话消息无稳定锚点、文档读权限票据不落盘。 + +--- + +## 1. PRD 需求拆解(后文逐条映射核对) + +| # | PRD 要求 | 类别 | +|---|---|---| +| R1 | Agent 连接页:选目标工具、安装 Skill/适配、选 Room/来源/读取类型/回写位置、自检(搜索/精读/候选回写)、示例查询+出处、暂停/更新/卸载 | 入口 | +| R2 | 五种读取:发现元信息、搜索、精读聊天、读取源文件、获取本次规则 | 读 | +| R3 | 任何搜索命中 → 能走到原会话或源文件 | 读 | +| R4 | 空结果 / 无权限 / 来源丢失 / 解析失败 → 不同状态 | 读 | +| R5 | 回写:会话增量、结果文本、产物文件、来源引用、记忆/Wiki/偏好候选;返回保存状态+对象 ID+可打开位置;重试复用同一提交标识 | 写 | +| R6 | 原会话记录按授权范围收录;长期内容按第 6 章三路处理 | 写 | +| R7 | 本机文件通过可控文件入口登记;远端 Agent 提交附件;校验类型、完整性、来源后提供预览 | 文件 | +| R8 | 修正后的新读取校验有效版本,未刷新的副本可见 | 版本 | +| G1–G3 | 发布门槛:搜索能精读原对话和源文件 / 范围控制有效 / 主动回写与后台同步无重复 | 验收 | + +> ✅ **2026-10-16 已拿到总表 PRD(《EverRoom 0.2.0 大版本更新 PRD》),本调研对应其第 7 章「外部 Agent 接入」(L2.5,Skills/MCP)**,L3 单元 L3.5.1–L3.5.5;交付在 M2(各工具取用)+ M5(适配增量/回写),契约依赖 M1(附录 A 公共对象);发布门槛 = 10.2「Skills」验收包。据此澄清三处此前的不确定: +> - **「三路处理」= 6.3.6 治理分流的「自动完成 / 按启用规则保存 / 生成问题卡」**,不是记忆/Wiki/偏好三个去向(提交载荷仍是 7.4 的记忆/Wiki/偏好候选,处理进治理三路)。 +> - **「获取本次规则」= 任务适用的技能、偏好、模板**(6.7 统一库;返回规则版本、作用范围、参考资源、优先级),不是 ingest 策略。 +> - 附录 A.3 定死六组能力(发现对象/搜索内容/读取对象与消息范围/读出处与文件/取规则/提交结果与候选)、**聊天搜索必须携带 conversation_id+message_id**、提交键幂等、以及 DeliveryReceipt(A.6)承载外部副本「待刷新/回执」。B.1 基线 E19 与本文 §2 的起点判断一致:「内部记忆/对话检索、源文档与出处、文档 MCP 读取已有;扩展外部统一发现→精读→源文件→回写;补搜索的会话 ID」。 + +--- + +## 2. 代码现状 + +### 2.1 已有资产(外部 Agent 访问可直接踩的) + +| 资产 | 位置 | 说明 | +|---|---|---| +| 通道 MCP 端点 | `apps/gateway/src/modules/agent/channel-mcp-routes.ts:19`(`POST /v1/mcp/everroom/:token`)、`channel-mcp-host.ts:154` | JSON-RPC 单次交换;token 每会话签发、24h、Room 范围 `ChannelMcpScope{agentSessionId, roomId}`;**只存内存,重启即失** | +| 文档能力 MCP 服务器 | `modules/documents/mcp-host.ts:348`(`everroom-context-room` v2.0.0);工具在 `capabilities/{query,create,review,office,comment,task}-plugin.ts` | `context_room_list/create`、`document_list/read`(返回 version+markdown+blocks+readReceipt)、`write_begin/append/commit/abort`、`patch_*`、`task_*` 共 25 个 | +| 检索工具 | `context-rooms/room-agent-tools.ts:35,73,101`、`packages/agent-runtime-pi/src/{memory,knowledge}/tools.ts` | `memory_search`、`conversation_search`、`room_context_get`、`wiki_search`、`wiki_read`——外部工具面的直接蓝本 | +| 全文搜索 | `modules/search/service.ts`(SQLite FTS5 + CJK bigram 分词,type/room/时间过滤),`POST /v1/search` | itemTypes: file/message/room/document/export | +| 会话读取 | `AgentService.getSnapshot`(`modules/agent/service.ts:1027`)、`buildSessionReferenceContext`(:994,32K 预算序列化器)、`GET /v1/agent/sessions/:id/events` | 全量导出需自行分页拼装(缺口⑨) | +| 文档读取 | `GET /v1/documents/:id/markdown|blocks|versions|diff`;`document_blocks` 稳定块锚点;`blockIndexMark`(`everroom://` 徵标往返,`documents/block-index-mark.ts:8`) | 版本链:`documents.version` + `doc_versions` + Yjs 检查点 | +| 文件入口与 CAS | `POST /v1/files`(multipart/base64,扩展名白名单 `files/service.ts:40`)、`POST /v1/file-imports`(sourceKind 含 **agent-generated**,pipelines `{room,wiki,memory}`)、`POST /v1/local-file-references`(登记本机路径不拷字节)、`GET /v1/files/:id/markdown|content`;存储 sha256 CAS(`files/storage.ts:34`) | R7 的「可控文件入口」基本齐:本机登记、远端附件、类型校验都有;完整性校验 = sha256 | +| 规则/技能底料 | `GET /v1/ingest/policies`、`GET /v1/ingest/filter/rules`、`GET /v1/knowledge/rules`、`GET /v1/knowledge/preferences`;写作风格/过滤规则/整理偏好三服务 | ⚠️ PRD 7.2 的「获取本次规则」指 **6.7 统一技能/偏好/模板库**(版本/范围/优先级)——统一库本身是 6.7.1 新增项(现仅三块底料),外部读取依赖它成型,或 P0 先暴露底料 | +| 幂等先例 | `agent_runs.idempotencyKey`(`(sessionId, idempotencyKey)` 唯一,`schema.ts:610`)、`room_merge_operations.idempotencyKey` | 回写幂等的表设计先例 | +| 候选先例 | `document_room_imports.relation=primary/candidate`(`schema.ts:1310`)、`writing_style_insights`(pending/snoozed/confirmed)、room-memory 晋升链 `room-memory:{roomId}:{itemId}`(`context-rooms/service.ts:762`) | 三路候选的状态机雏形 | +| 外部会话表 | `external_agent_threads / external_agent_messages`(`schema.ts:691,719`,contentHash+ordinal) | 「原会话记录按授权范围收录」的天然落点,导入 Codex/Claude/OpenClaw 线程已有先例(`data-migrations/service.ts:283`) | +| 网关发现 | `dataDir/runtime/gateway.json` 清单(pid/baseUrl/token),桌面轮询对接 | 第三方进程理论上也能读,但非设计用途 | +| MCP SDK 依赖 | `@modelcontextprotocol/sdk ^1.30.0` 已在依赖树 | 升级 v2 即可(见 §3.1) | + +### 2.2 缺口清单(合并两路代码调查) + +1. **无稳定地址**:端口默认 `0`(随机),token 每次启动随机;只有 manifest 文件这一种发现方式。 +2. **无第三方凭证体系**:没有面向第三方的 token 签发/持久化/吊销 UI 或接口;channel token 是内存 Map,重启蒸发。 +3. **无范围模型**:全局 bearer 一把梭(`server/auth.ts:4`,仅 `/v1/health*`、`/docs`、`/v1/mcp/everroom/` 豁免);没有按 Room、按读写能力、按回写位置的授权;无按调用方审计。 +4. **无通用外部提交幂等存储**:幂等只存在于个别内部实体,没有「客户端提交标识 → 去重 → 返回原结果」的通用 inbox。 +5. **会话消息无稳定锚点**:只有 createdAt 排序,引用会话内容没有类似 `document_blocks` 的锚(引用只能「指到会话+时间」)。 +6. **文档读权限票据内存态**:readReceipt TTL 10 分钟且不落盘(`capabilities/read-authority.ts:13`),长跑外部 Agent 持票会失效。 +7. **Room 范围记忆检索纯词法**:`MemoryService.searchRoomMemories:733` 是 ≤20 行快照上的 AND 子串;混合检索只在 MemoryCore 全局搜索里有。 +8. **room_memory_attributions 快照漂移**:内容是写入时快照,MemoryCore 侧编辑后不同步。 +9. **无整会话导出端点**:只有 32K 预算的内部序列化器,全保真导出要自己分页 `agent_messages`+`agent_events`。 +10. **Wiki 写直连 KS**:无网关侧 journal/provenance(文档有 operation/event 表,Wiki 没有)。 +11. **无 CORS**:浏览器工具跨域打不进网关(CLI 无碍)。 +12. ~~PRD 第 6 章缺位~~ → **已对齐**:三路处理 = 6.3.6 自动完成/按启用规则/生成问题卡(见 §1)。 +13. **审计无调用方维度**:`audit_logs` 表存在但没按 caller 切分。 + +### 2.3 顺带:未跟踪的 `apps/gateway/src/modules/connector/` + +是整仓 vendored 的 **NxcoreAI fork 的 Nango**(`@nangohq/nango` 0.71.4,自带 .git/node_modules),入站连接器方向的调研遗留物,`create-server.ts` 零引用、tsup 不打包,纯属占着工作区。与出站 Agent 访问无关(Nango 的 MCP 内容是「连到别人家 MCP」的 provider 目录,不是服务端框架)。建议移出 `src/modules/`(避免 glob 进构建)或加 ignore;按你的规则我不动它。 + +--- + +## 3. 规范与生态现状(2026-10) + +### 3.1 MCP:两个协议时代,SDK v2 一次覆盖 + +- 最新规范 **2026-07-28**(断代重构):无协议级会话、无 `initialize` 握手(改 `server/discover` + 每请求 `_meta`)、SSE 会被 `subscriptions/listen` 单流取代、elicitation/sampling 改 MRTR(`input_required` 结果+重试)、新增缓存提示与必带 `Mcp-Method/Mcp-Name` 头。**客户端舰队混编**:Claude Code 自 v2.1.274 起默认双运行时按服务器协商;claude.ai 托管端仍在 2025 时代。 +- **TypeScript SDK v2**(`@modelcontextprotocol/server` + fastify/express/hono 适配器)的 `createMcpHandler` 默认同时服务两个时代(`legacy: 'stateless'`),每 HTTP 请求跑一次工厂 `{era, authInfo, requestInfo}`——**按连接动态注册工具集**正好是我们 scope 过滤需要的形态。 +- 传输:Streamable HTTP 单端点 POST;stdio 仅本地子进程场景;HTTP+SSE 已废弃。 +- **OAuth(云端用)**:MCP 服务器 = OAuth 2.1 资源服务器。RFC 9728 受保护资源元数据、RFC 8414 发现、RFC 8707 `resource` 参数、PKCE S256;2026 版将 DCR 换成 CIMD(客户端 ID 即 HTTPS 元数据文档 URL),实践上两者都暴露;`401` 带 `scope`、`403 insufficient_scope` 驱动升权。Claude 系回调要求接受 `https://claude.ai/api/mcp/auth_callback` + 端口无关的 localhost 回调;OAuth 端点 10s 预算。 +- **工具标注是硬要求**:Anthropic 连接器评审要求读工具 `title` + `readOnlyHint: true`、写工具 `destructiveHint: true`(驱动 Claude 自动批准策略);工具名 ≤64 字符;禁止 prompt-injection 式描述。 +- **搜索→精读的官方姿势**:工具结果返回 `resource_link`(`everroom://…` 自定义 scheme,URI 指不可直连时用自定义 scheme 而非 https)→ 客户端 `resources/read` 深读;资源模板用 RFC 6570(`everroom://rooms/{roomId}/docs/{docId}`);`outputSchema`+`structuredContent` 结构化输出;分页 cursor。 +- **长任务**:Tasks 扩展(`io.modelcontextprotocol/tasks`)客户端支持尚稀疏,规范推荐先做「句柄 + 轮询工具」模式(创建工具返回 jobId + get_job),全客户端可用。 +- MCP Registry 仍是 preview、不做私有服务器发现;云端上市走 claude.ai Connectors 目录。 + +### 3.2 Skills:已是跨客户端开放标准 + +- **agentskills.io 标准**:`SKILL.md` + 六个规范字段(`name/description/license/compatibility/metadata/allowed-tools`),渐进披露三级(元数据 ~100 token → 正文 <5K token → 按需文件)。**46 个客户端支持**:Claude 全家、ChatGPT/Codex、Cursor、Copilot、VS Code、Gemini CLI、OpenCode、Windsurf 等。可移植的产品 skill 应只用规范六字段(Claude 私有扩展如 shell 注入在其他端不跑)。 +- **Claude 插件可同时打包 skill + MCP 配置**:plugin 根 `.claude-plugin/plugin.json` + `skills//SKILL.md` + `.mcp.json`(`{"type":"http","url":…}`,工具名 `mcp__plugin_…`),`/plugin marketplace add` + `/plugin install` 一次装齐。官方定位原话:「连接器 = 触达产品能力;技能 = 教工作流;大多数产品插件两者都带」。 +- **Skills-over-MCP 扩展**(`io.modelcontextprotocol/skills`,Final):MCP 服务器可直接下发 skill(`skills/list/get` + `skill://` URI)——未来可让网关直接serve EverRoom 工作流技能,ChatGPT 已部分支持。 +- 分发渠道对照:Claude Code `~/.claude/skills`、`.claude/skills`、plugin;Cursor `.cursor/skills`(兼容读 `.claude/skills`);Codex `.codex/skills`;Gemini `.gemini/skills`;Windsurf `.windsurf/skills`。 + +### 3.3 客户端远程 MCP + OAuth 支持矩阵(2026-10) + +| 客户端 | 远程 Streamable HTTP + OAuth | Skills | +|---|---|---| +| Claude Code | ✅(`claude mcp add --transport http`、DCR/CIMD、headersHelper、`/mcp auth --scope`) | ✅(含 plugin) | +| claude.ai / Desktop | ✅ Connectors(全量计划开放) | ✅ | +| Cursor | ✅(DCR + 静态 client-id;免费版不能加自定义 MCP) | ✅ | +| Codex CLI / ChatGPT | ✅(`streamable_http`、`auth:"oauth"`、`http_headers_helper`) | ✅ | +| Windsurf | ✅ | ✅ | +| Gemini CLI | ✅(`httpUrl` + DCR、includeTools/excludeTools) | ✅ | + +结论:**「远程 HTTP MCP + 一份标准 SKILL.md」即可覆盖全部目标工具**,无需 per-tool 适配器;per-tool 差异只体现在「配置文件写到哪、token 怎么带」(连接页代写解决)。 + +--- + +## 4. 竞品架构 + +| 产品 | 部署 | 鉴权/范围 | 值得抄 | +|---|---|---|---| +| **Linear** | 远程托管 `mcp.linear.app/mcp`,另有独立 `/mcp/readonly` 端点 | OAuth 2.1+DCR;`read` scope 或 readonly 端点双路只读 | ①独立只读端点;②prompt library 按工作流教行为(「先展示再改」「不猜匹配」) | +| **Notion** | 远程托管 `mcp.notion.com/mcp`(自托管版引导弃用) | 用户 OAuth(继承页面权限)+ 管理员 workspace 连接(精确选工具集) | ①`page_last_edited_at`/`verification.state` 版本字段全链路;②`notion://` 稳定内部 URI + 人类 URL 并列;③删除要 `user_confirmed:true`;④`notion-get-tool-access` 自描述工具;⑤`notion://docs/*` 文档资源按需教 Agent | +| **Slack** | 远程托管 `mcp.slack.com/mcp` | **拒绝 DCR**:每个 MCP 连接都挂在注册 App 身份上,企业管理员审批/审计/IP 白名单原样生效;scope 直接映射经典 Web API scope,逐工具对照表 | ①两段式文件上传(拿 URL→传→确认);②scope=产品既有权限模型而非 MCP 新造 | +| **GitHub** | 双轨:远程托管(GA)+ 本地 Docker/二进制(隔离网) | OAuth 2.1+PKCE;**逐工具 OAuth challenge scope**(调写工具触发增量授权);org 级工具集开关 | ①动词合并(`issue_read` 带 `method:get|list|add_labels`);②`--read-only` 全局开关;③`fields` 投影参数缩响应;④`get_rate_limit` 自省 | +| **Obsidian 生态**(最近邻) | 无官方,社区三分(Local REST 桥 / URI 命令 / 裸文件) | 本地 API key | `obsidian://` URI 转换工具(link-back 产品);教训:本地优先产品要给 **app 级语义搜索**,不能让 Agent 裸 grep | +| **Mem0/OpenMemory**(记忆层最近邻) | 托管 `mcp.mem0.ai`(API key bearer)+ 自托管 OpenMemory(本地 Docker、per-project 命名空间、跨 Agent 共享) | API key | 「本地记忆中枢」形态与 EverRoom 槽位相同;我们的差异点 = 记忆锚在真实 Room/会话/文档上,且可回到原会话 | + +**跨竞品结论**:没有一家做客户端幂等提交键(Notion 靠异步任务 id + 分段上传近似达成);版本字段只有 Notion 认真做。这两点都是 PRD 明确要求 → 直接做成长板。 + +--- + +## 5. 方案建议 + +### 5.1 总体架构 + +``` +外部 Agent(Claude Code / Cursor / Codex / …) + │ Streamable HTTP, Bearer 连接 token(本地) / OAuth 2.1(云端) + ▼ +网关新端点 POST /v1/mcp/agents ← TS SDK v2 createMcpHandler,按连接 scope 过滤工具 + ├─ 读工具(五件套)→ 复用现有 service:search / memory / agent / documents / files / knowledge + ├─ 回写工具 → agent_submissions(幂等 inbox)→ 三路候选(既有 promotion/candidate 机制) + ├─ 资源 everroom://… → resources/read 深读 + └─ agent_connections(凭证/范围/状态) ← 桌面「Agent 连接页」管理(签发/暂停/更新/卸载) +Skill(agentskills 标准)+ 各工具配置文件 ← 连接页代写;Claude 生态另出 plugin 打包 +``` + +- **本地优先**:桌面网关常驻 + **固定可配端口**(新配置项,默认仍随机的现状保留给不想开的人);连接 token 持久化落库、可吊销。 +- **云端同构**:同一 handler,前面换 OAuth 资源服务器中间件(SDK 自带 `requireBearerAuth` + RFC 9728 元数据路由 + 逐工具 `scopeChallenge` 升权)。TS SDK v2 有 fastify 适配器,可直接挂进现有 Fastify 网关。 +- 2026 规范无会话 → 跨调用状态全部显式句柄化(readReceipt/jobId/submissionId),对多租户云端反而更省事。 + +### 5.2 凭证与范围模型(R1 的核心) + +新表 `agent_connections`: + +``` +id, name, tool(claude-code/cursor/codex/generic), tokenHash, +scopes JSON { + roomIds: [] | "all", // 可访问的 Room/来源 + capabilities: [read_metadata, search, read_conversations, + read_files, read_rules, write_session, + write_candidates, register_files], // 允许读取的类型+写能力 + writebackRoomId, // 回写位置(候选/会话增量的归属 Room) +}, +status(active/paused/revoked), createdAt, lastSeenAt, exampleQueryAt, revokedAt +``` + +- 本地 token:随机 32B base64url,仅存 hash;HTTP `Authorization: Bearer`(各客户端 http 型配置均支持 headers;Claude Code 另有 headersHelper、Codex 有 `http_headers_helper` 可动态读 `runtime/gateway.json` 兜底随机端口场景)。 +- 云端 scopes 映射 OAuth scope 字符串(`rooms:read rooms:write candidates:write …`),401 challenge 带 scope、403 step-up。 +- 每个 MCP 请求过中间件 → 取连接 → 工厂按 `scopes.capabilities` 注册可用工具子集 → 工具内部再按 `roomIds` 过滤数据(双层:工具可见性 + 数据行过滤,R4 的 no_permission 与 G2 都靠这层)。 + +### 5.3 工具面草案(对外 MCP,前缀 `everroom_`) + +| 工具 | 对应 PRD | 形态 | 复用 | +|---|---|---|---| +| `everroom_list_rooms` | R2 发现 | 只读;返回 title/kind/summary/`revision`/updatedAt/deepLink | context-rooms snapshot + rooms 注册表 | +| `everroom_search` | R2 搜索 | 只读;kinds=room/conversation/document/file/memory/wiki,FTS5 + MemoryCore + KS 分发;每命中带 `resource_link`(everroom:// URI)+ snippet + version + updatedAt + **稳定对象 ID(会话命中必须含 conversation_id+message_id,A.3 硬要求)** | search/memory/knowledge service | +| `everroom_fetch` | R2 精读 + R3 + R8 | 只读;按 everroom:// URI 深读;会话分页、文档 markdown/blocks、记忆条目;可选 `basedOnVersion` → 不匹配返回 `stale` + 最新版本号 | AgentService.getSnapshot、documents markdown/blocks | +| `everroom_read_file` | R2 源文件 + R7 预览 | 只读;CAS 内容/markdown + contentHash + 预览 deepLink | files/ingest routes | +| `everroom_get_rules` | R2 规则 | 只读;**任务适用的技能/偏好/模板(6.7 统一库:规则版本、作用范围、参考资源、优先级)** + 本连接授权范围 | 6.7 统一技能服务(新)+ 现有写作风格/过滤/整理偏好底料 | +| `everroom_get_status` | R4 | 只读自描述;启用模块(ASR/memory/wiki)、限流状态、能力可用性(竞品 Notion/GITHUB 同款,防 Agent 瞎重试烧任务——见 ASR 烧死教训) | external-calls/usage | +| `everroom_register_file` | R7 | 写;本机路径登记(local-file-references)或字节提交(files,sha256 完整性+类型白名单) | 已有入口 | +| `everroom_submit` | R5+R6+G3 | 写;**统一提交**:`submission_id`(客户端生成)+ payload[]{session_increment | result_text | artifact_file(ref) | citations | candidate(memory/wiki/preference)};服务端 `(connectionId, submissionId)` 唯一去重,重复返回原结果;每项返回 status/objectId/deepLink | 新表 agent_submissions + 既有候选/晋升链 + external_agent_threads | +| `everroom_submit_status` | R5 | 只读;按 submission_id 查处理状态(后台三路处理异步化后的轮询句柄) | 同上 | + +状态语义(R4,全部进 `structuredContent`,带 `outputSchema`):`ok` / `empty`(0 命中)/ `no_permission`(连接 scope 不足,附所需 scope)/ `source_lost`(对象已删/不存在,附删除前最后版本)/ `parse_failed`(附原因与建议替代来源)/ `stale`(R8:`basedOnVersion` 过期,附最新 version,旧副本内容仍可带 `superseded:true` 返回——「未刷新的副本可见」)/ `duplicate`(submission 重放,返回原结果)、`pending_refresh`(PRD 7.5 治理结果同步:旧缓存访问时给最新引用或待刷新提示;支持推送的工具记 DeliveryReceipt 回执,离线/文件交接目标显示最后已知版本——A.6 契约)。 + +版本字段约定(R8):Room=`room_overviews.revision`;文档=`documents.version`+contentHash;文件=sha256;记忆=updatedAt;全部进读结果,deepLink 用桌面协议打开原对象(R3「走到原会话或源文件」的服务端一半,客户端一半靠 skill 教)。 + +### 5.4 资源 URI 空间(RFC 6570 模板) + +``` +everroom://rooms everroom://rooms/{roomId}/overview +everroom://conversations/{sessionId} everroom://conversations/{sessionId}/messages?before= +everroom://documents/{docId} everroom://documents/{docId}/blocks/{blockId} +everroom://files/{fileId} everroom://memory/{memoryId} +everroom://wikis/{roomId}/{path} everroom://rules +``` + +人类可打开位置 = 桌面 deep link(如 `everroom://open/room/{id}`),搜索命中同时给资源 URI(Agent 用)+ deep link(用户用)——Notion/GitHub 同款双轨。 + +### 5.5 Skill 与分发 + +- 一份 `everroom-agent` skill(agentskills 六字段),正文教:何时搜什么类型 → fetch 精读 → 引用规范(带 version)→ 何时提候选(memory vs wiki vs preference 的判据)→ 提交规则(先展示再写、submission_id 复用)。`references/` 放工具清单与 URI 空间说明。 +- 连接页按所选工具**代写配置**(解决「装 Skill + 连 MCP」的最后一步): + +| 工具 | MCP 配置 | Skill 位置 | +|---|---|---| +| Claude Code | `~/.claude.json`(user scope)或项目 `.mcp.json`,headers 带 token | `~/.claude/skills/everroom-agent/`(或 plugin) | +| Cursor | `~/.cursor/mcp.json`(headers) | `~/.cursor/skills/` | +| Codex | `~/.codex/config.toml` `[mcp_servers.everroom]` + `http_headers` | `~/.codex/skills/` | +| Gemini CLI | `settings.json` `httpUrl` | `.gemini/skills/` | +| Cherry Studio | 设置 → MCP 服务器(按目标版本验证,7.3 适配矩阵) | —(宿主无统一 skill 机制,靠工具描述教工作流) | +| OpenClaw / Hermes | 按已验证版本的技能/工具/CLI 通道(7.3) | 各自技能目录,待适配验证 | +| 通用 | 打印 URL+token 让用户手配 | — | + +PRD 7.3 的验收目标工具为 **Cherry Studio / Codex / OpenClaw / Hermes 四家 + Claude Code(既有)**;Cursor/Gemini/Windsurf 不在验收清单,但走开放协议天然兼容、零额外成本。 + +- Claude 生态可选再出 plugin(`.mcp.json` + skills 打包,`/plugin install everroom@` 一次装齐)——P1。 +- 云端上市再走 claude.ai Connectors 目录 + MCP Registry(均 P2,非必需)。 + +### 5.6 连接页功能(对 R1 逐条) + +1. 选择目标工具 → 2. 勾选 Room/来源 + 读取类型 + 回写位置 → 3. 签发连接(落 `agent_connections`,代写配置+skill)→ 4. **自检**:三项 smoke test(一次搜索、一次精读、一次候选回写提交后撤销)实跑 MCP 端点 → 5. **示例查询展示**:跑一条真实查询,展示命中+出处+deepLink(即 PRD「展示一次示例查询及出处」;参考 Linear 的 in-browser playground)→ 6. 管理:暂停(token 拒绝但保留)/更新(改 scope,token 可选轮换)/卸载(吊销 + 删代写文件 + 归档连接记录)。 +7. **调试页**(7.5):最近调用、状态、数据量、已返回来源和回写结果;敏感凭据独立管理(token 不进调试日志)。连接状态按 7.3 分项展示:历史导入 / 持续同步 / 按需读取 / 成果回写 / 导出交接。 + +### 5.7 发布门槛验收设计(G1–G3) + +- **G1 搜索能精读原对话和源文件**:端到端用例——`everroom_search` 命中会话 → `everroom_fetch` 拿到全量转录分页;命中文件 → `everroom_read_file` 拿到 markdown/内容,contentHash 对得上 CAS。 +- **G2 范围控制有效**:双连接用例——连接 A 只授权 Room1,搜索/精读/回写对 Room2 全部返回 `no_permission`;`writebackRoomId` 之外的候选提交被拒;工具列表本身不含未授权能力。 +- **G3 主动回写与后台同步无重复**:同一 `submission_id` 重放 N 次落 1 条(返回 `duplicate`+原 objectId);Agent 提交的产物文件经 CAS contentHash 与后台 ingest 的同内容文件天然合一(CAS 已按内容寻址);候选三路各有去重键(contentHash / 快照内容)。PRD 7.3/9.3 进一步要求**两条回写链共用来源身份**:同步服务按完成轮次/空闲窗口收录原会话增量(如 Codex sessions 解析),Skill 调用提交结构化产物/决定/候选,两链按「平台+原会话/消息/文件 ID+版本」合并到同一对象。 +- PRD 7.5 验收链(G1–G3 用例骨架):外部搜索 → 原对话前后文 → 记忆源文件 → 生成结果 → 候选回写 → 下一次读取。 + +### 5.8 需要新建/改造的 schema 与模块 + +- 新表:`agent_connections`、`agent_submissions`(幂等 inbox,含三路候选分发状态)。 +- 改造:网关固定端口选项;MCP 端点中间件(连接解析+scope 过滤工厂);readReceipt 落盘或对外通道豁免(外部 Agent 长跑场景);`external_agent_threads` 增加 submission 来源关联。 +- 升级 `@modelcontextprotocol/sdk` 1.x → 2.x(网关内既有 host 一并评估迁移,channel-mcp 保持不动风险可控)。 +- 会话锚点(缺口⑤)P0 可先用「会话+序号」引用,P1 再上 message anchor 表。 + +--- + +## 6. 风险与开放问题 + +1. ~~PRD 第 6 章不在仓库~~ → 已对齐:三路处理 = 6.3.6 自动完成/按启用规则/生成问题卡;`agent_submissions` 分发语义按此锁(候选进治理管线分流,不直接落库)。 +2. **固定端口 vs 动态发现**:固定端口有占用冲突与本地攻击面问题(token 在 header,仍建议默认仅 127.0.0.1 + 可选关闭);兜底方案是 helper 脚本读 `runtime/gateway.json` 动态注入 URL/header。需要拍板。 +3. **readReceipt 内存态**与外部长跑 Agent 冲突,需落盘或对外通道改签发逻辑。 +4. **SDK 1.x→2.x 迁移**牵动既有三个内部 MCP host(documents/notifications/channel),需回归。 +5. **会话消息锚点**:A.3 要求搜索命中带 message_id——内部 `agent_messages.id` 可直接暴露,导入线程用 `external_agent_messages.ordinal`;块级锚(对标 document_blocks)P1 再上。 +6. Room 范围记忆检索纯词法(缺口⑦):外部 Agent 高频搜索 Room 记忆时体验一般,P1 接 MemoryCore 混合检索的 room 过滤。 +7. 云端 OAuth 全家桶(CIMD/DCR 双暴露、回调白名单、10s 预算)工作量不小,且 claude.ai Desktop OAuth 曾有社区报告的回归——本地 token 优先、云端 P1 再上有道理。 +8. 工作区的 vendored Nango 目录建议移出 `src/modules/`(见 §2.3),避免构建误扫。 + +--- + +## 7. 分阶段落地建议 + +**P0(对齐发布门槛 10.2;对应 PRD M1 契约 + M2 各工具取用)**——M1 需先落 A.1/A.3 公共对象:SourceRef/EvidenceRef、ExecutionRecord/ResultEnvelope(attempt/幂等键)、Skill/Preference/Template、DeliveryReceipt +1. `agent_connections` + `agent_submissions` 表 + 连接页(签发/scope/自检/示例查询/暂停/卸载)。 +2. 网关固定端口选项 + `POST /v1/mcp/agents` 端点(SDK v2,Bearer 连接 token,按 scope 工厂注册工具)。 +3. 五件套读工具 + `everroom_get_status` + 状态语义(empty/no_permission/source_lost/parse_failed/stale)+ 版本字段。 +4. `everroom_register_file` + `everroom_submit`/`submit_status`(幂等、三路候选、deepLink 返回)。 +5. `everroom-agent` skill(标准六字段)+ 各工具配置代写。 +6. G1–G3 验收用例。 + +**P1** +- 云端 OAuth 2.1 资源服务器 + claude.ai Connectors;Claude plugin 打包分发;独立 `/mcp/readonly` 端点;message anchor 表;Room 记忆混合检索;readReceipt 落盘;审计按调用方维度。 +- 四家适配增量与回写收尾(PRD M5);**开放交付件**:Skill/连接协议说明、适配样例、安装说明(10.3 开放交付要求,发布清单项)。 + +**P2** +- `subscriptions/listen` 变更通知;Tasks 扩展长任务;Skills-over-MCP(网关直接下发 skill);MCP Registry / 目录上市。 + +--- + +## 8. 主要参考 + +- MCP 规范 2026-07-28(transports/authorization/tools/resources/pagination/elicitation): modelcontextprotocol.io/specification/2026-07-28 +- TS SDK v2(packages/serving-http/authorization/legacy-clients): ts.sdk.modelcontextprotocol.io/v2 +- Anthropic 连接器构建与评审标准: claude.com/docs/connectors/building(review-criteria:读写分离+annotations 硬要求) +- Agent Skills 开放标准与客户端矩阵: agentskills.io(specification / clients) +- Claude Code MCP / Skills / Plugins: code.claude.com/docs/en/{mcp,skills,plugins} +- Skills-over-MCP / Tasks 扩展: modelcontextprotocol.io/extensions +- 竞品:linear.app/docs/mcp;developers.notion.com/guides/mcp;docs.slack.dev/ai/slack-mcp-server;github.com/github/github-mcp-server;mem0.ai/openmemory